First commit. Version 0.1.0

This commit is contained in:
2026-05-10 09:58:23 +02:00
parent af105b7f7d
commit bbab5e238d
635 changed files with 53627 additions and 175 deletions

158
src/gles_render/clip.rs Normal file
View File

@@ -0,0 +1,158 @@
// 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.
use glow::HasContext;
use crate::types::{ Color, Rect };
use super::GlesCanvas;
impl GlesCanvas
{
pub fn set_clip_rects( &mut self, rects: &[Rect] )
{
// 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 } );
}
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;
}
/// 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(),
}
}
}

View File

@@ -0,0 +1,342 @@
// 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 )
{
// SAFETY: rebinds canvas-owned FBO + viewport + scissor. All values
// (`self.fbo`, `self.width`, `self.height`, `self.clip_scissor`)
// live as long as `&self`, and the bind is idempotent.
unsafe
{
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
match self.clip_scissor
{
Some( r ) =>
{
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 );
}
}
}
}
/// 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.
}
}

293
src/gles_render/helpers.rs Normal file
View File

@@ -0,0 +1,293 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Backend-neutral free helpers for the GLES renderer: MVP matrix
//! construction, shader compilation, FBO / texture allocation,
//! system-font lookup, small typed-handle extractors. Visible only
//! within `crate::gles_render` — callers always go through
//! `GlesCanvas`'s public methods.
use glow::HasContext;
use crate::types::Rect;
use super::GlesVersion;
pub( super ) fn ortho_rect( vp_w: u32, vp_h: u32, rect: Rect ) -> [f32; 16]
{
let w = vp_w as f32;
let h = vp_h as f32;
let sx = rect.width * 2.0 / w;
let sy = rect.height * 2.0 / h;
let tx = rect.x * 2.0 / w - 1.0;
let ty = 1.0 - rect.y * 2.0 / h - sy;
[
sx, 0.0, 0.0, 0.0,
0.0, sy, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
tx, ty, 0.0, 1.0,
]
}
/// Upload `data` as a 2D RGBA texture, premultiplying alpha into the
/// upload buffer.
///
/// `data` is straight-alpha (the format CPU PNG / JPG decoders
/// produce). GL_LINEAR sampling interpolates RGB and A independently
/// across texels: at the antialiased edge of an icon — say a fully
/// opaque black texel next to a fully transparent white texel —
/// straight-alpha interpolation midway gives `( 0.5, 0.5, 0.5, 0.5 )`
/// which composes onto the destination as a 50 % gray halo, while
/// premultiplied interpolation gives `( 0, 0, 0, 0.5 )` and composes
/// as transparent black. Premultiplying once at upload eliminates
/// the halo on every later draw.
///
/// Defensive: when the declared `w × h × 4` size does not match
/// `data.len()`, the function refuses the upload, logs once via
/// stderr, and substitutes a 1×1 transparent placeholder. The GL
/// driver would otherwise read past the slice end inside
/// `tex_image_2d`, since it trusts the dimensions over the slice
/// length.
pub( super ) fn upload_rgba_texture( gl: &glow::Context, version: GlesVersion, data: &[u8], w: i32, h: i32 ) -> glow::Texture
{
let expected = ( w as i64 ).saturating_mul( h as i64 ).saturating_mul( 4 );
let valid = w > 0 && h > 0 && expected >= 0 && expected as usize == data.len();
let placeholder: [u8; 4] = [ 0, 0, 0, 0 ];
let ( safe_w, safe_h, safe_data ): ( i32, i32, &[u8] ) = if valid
{
( w, h, data )
}
else
{
eprintln!(
"[ltk] upload_rgba_texture: refusing malformed upload — \
{w}×{h} declared, {} bytes provided, expected {}",
data.len(),
expected.max( 0 ),
);
( 1, 1, &placeholder[..] )
};
let mut premul = Vec::with_capacity( safe_data.len() );
for px in safe_data.chunks_exact( 4 )
{
let a = px[3] as u32;
// `(c * a + 127) / 255` — round-to-nearest integer scale.
// Plain `c * a / 255` truncates, leaving fully-opaque pixels
// with rgb < their straight value (visibly darker icons).
premul.push( ( ( px[0] as u32 * a + 127 ) / 255 ) as u8 );
premul.push( ( ( px[1] as u32 * a + 127 ) / 255 ) as u8 );
premul.push( ( ( px[2] as u32 * a + 127 ) / 255 ) as u8 );
premul.push( px[3] );
}
// `RGBA8` (sized) on GLES 3 forces 8-bit-per-channel storage; the
// unsized `RGBA` token leaves the format up to the driver and some
// mobile GPUs pick a 4-bits-per-channel or 565+A4 layout for it,
// which shows up as banded / colour-quantised icons. ES 2 has no
// `RGBA8` constant, so we fall back to the unsized form there
// (matching `alloc_fbo_tex`).
let internal_format = match version
{
GlesVersion::V3 => glow::RGBA8 as i32,
GlesVersion::V2 => glow::RGBA as i32,
};
// SAFETY: caller's GL context is current. The most important
// invariant is on the upload size: the validity check above
// guarantees `safe_w * safe_h * 4 == safe_data.len() == premul.len()`,
// or replaces the upload with a 1×1 transparent placeholder when the
// caller provided malformed dimensions. Without this guard the GLES
// driver trusts the dimensions and reads past the slice end inside
// `tex_image_2d` (the original UB this defensive code prevents).
// `RGBA + UNSIGNED_BYTE` is universally supported. We unbind on
// exit to avoid stranding TEXTURE_2D bound to the new texture.
unsafe
{
let tex = gl.create_texture().unwrap();
gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::LINEAR as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 );
gl.tex_image_2d(
glow::TEXTURE_2D, 0, internal_format,
safe_w, safe_h, 0, glow::RGBA, glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice( Some( &premul ) ),
);
gl.bind_texture( glow::TEXTURE_2D, None );
tex
}
}
/// Allocate a fresh color texture sized for the FBO. Internal format is sized
/// (`GL_RGBA8`) on ES3 and unsized (`GL_RGBA`) on ES2 — the unsized form is
/// required for color-renderable textures on ES2 drivers.
pub( super ) unsafe fn alloc_fbo_tex( gl: &glow::Context, version: GlesVersion, w: u32, h: u32 ) -> glow::Texture
{
// SAFETY: caller guarantees the GL context bound to `gl` is current
// on this thread. `tex_image_2d` with a `None` data slice allocates
// uninitialised storage of size `w*h*4` bytes (RGBA8 / unsized RGBA);
// the size and format combination is valid on every GLES2 / GLES3
// driver. The bind / unbind pair leaves TEXTURE_2D unbound on exit
// so we don't strand a binding the caller might rely on.
unsafe
{
let tex = gl.create_texture().expect( "create_texture" );
gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
let internal_format = match version
{
GlesVersion::V3 => glow::RGBA8 as i32,
GlesVersion::V2 => glow::RGBA as i32,
};
gl.tex_image_2d(
glow::TEXTURE_2D, 0, internal_format,
w as i32, h as i32, 0, glow::RGBA, glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice( None ),
);
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 );
gl.bind_texture( glow::TEXTURE_2D, None );
tex
}
}
pub( super ) fn upload_alpha_texture( gl: &glow::Context, data: &[u8], w: i32, h: i32 ) -> glow::Texture
{
// SAFETY: caller's GL context is current. Caller is responsible for the
// `data.len() == w * h` invariant — `upload_alpha_texture` is only called
// from the glyph atlas path on bitmaps fontdue produced at the same
// dimensions. UNPACK_ALIGNMENT is set to 1 for the upload (1 byte/pixel)
// and restored to the GL default of 4 immediately after, so subsequent
// uploads are not affected.
unsafe
{
let tex = gl.create_texture().unwrap();
gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
// NEAREST, not LINEAR. Glyph atlases are drawn 1:1 with their bitmap
// (dest size = texture size, integer-aligned position). Mathematically
// LINEAR at an exact texel center collapses to the texel value, but
// mediump precision in the fragment shader (and the `1 - v_uv.y` flip)
// can drift the sample point a fraction of a texel off-center, and the
// LINEAR filter then blends with neighbours — visible as soft, washed
// stems, especially at small sizes. NEAREST snaps to the correct texel
// every time, matching the software path's pixel-perfect bitmap copy.
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 );
// GL_LUMINANCE is 1 byte/pixel; default UNPACK_ALIGNMENT (4) misreads
// any row whose width is not a multiple of 4, scrambling those glyphs.
gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 1 );
gl.tex_image_2d(
glow::TEXTURE_2D, 0, glow::LUMINANCE as i32,
w, h, 0, glow::LUMINANCE, glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice( Some( data ) ),
);
gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 4 );
gl.bind_texture( glow::TEXTURE_2D, None );
tex
}
}
pub( super ) fn compile_program( gl: &glow::Context, vert_src: &str, frag_src: &str ) -> glow::Program
{
// SAFETY: caller's GL context is current. The shaders are compiled and
// linked inside this block; on success `program` is a fresh, fully linked
// GL program object. Vertex / fragment shaders are released with
// `delete_shader` after attach + link — they are flagged for deletion and
// freed when the program is deleted, which is the canonical pattern.
// Compile / link asserts panic with the driver's info log instead of
// returning a half-broken program (callers cannot recover anyway).
unsafe
{
let program = gl.create_program().unwrap();
let vs = gl.create_shader( glow::VERTEX_SHADER ).unwrap();
gl.shader_source( vs, vert_src );
gl.compile_shader( vs );
assert!( gl.get_shader_compile_status( vs ), "VS: {}", gl.get_shader_info_log( vs ) );
let fs = gl.create_shader( glow::FRAGMENT_SHADER ).unwrap();
gl.shader_source( fs, frag_src );
gl.compile_shader( fs );
assert!( gl.get_shader_compile_status( fs ), "FS: {}", gl.get_shader_info_log( fs ) );
gl.attach_shader( program, vs );
gl.attach_shader( program, fs );
gl.bind_attrib_location( program, 0, "a_pos" );
gl.link_program( program );
assert!( gl.get_program_link_status( program ), "Link: {}", gl.get_program_info_log( program ) );
gl.delete_shader( vs );
gl.delete_shader( fs );
program
}
}
pub( super ) fn bytemuck_cast_slice( floats: &[f32] ) -> &[u8]
{
// SAFETY: `f32` has the same allocation provenance / validity as
// `[u8; 4]` for any bit pattern (every bit pattern is a valid byte;
// `f32` admits NaN payloads but those are valid `u8` reads). The
// returned slice's lifetime is tied to the input slice's lifetime
// through the function signature so the read window cannot outlive
// the underlying storage. `len * 4` cannot overflow `usize` because
// it would require an `f32` slice exceeding `usize::MAX / 4` bytes
// — physically impossible on any addressable target.
unsafe
{
std::slice::from_raw_parts(
floats.as_ptr() as *const u8,
floats.len() * 4,
)
}
}
pub( super ) fn native_texture_id( texture: glow::Texture ) -> u32
{
texture.0.get()
}
pub( super ) fn native_framebuffer_id( framebuffer: glow::Framebuffer ) -> u32
{
framebuffer.0.get()
}
const SYSTEM_FONT_CANDIDATES: &[&str] =
&[
// Debian `fonts-sora` — the canonical path the `ltk-theme-default`
// package depends on. Listed first so Sora wins as the default
// font whenever that package is installed.
"/usr/share/fonts/opentype/sora/Sora-Regular.otf",
"/usr/share/fonts/truetype/sora/Sora-Regular.ttf",
"/usr/share/fonts/sora/Sora-Regular.ttf",
"/usr/share/fonts/TTF/Sora-Regular.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
];
/// Load the bytes of a default system font. Tries
/// [`SYSTEM_FONT_CANDIDATES`] in order; falls back to the embedded
/// [`crate::theme::fallback::FALLBACK_FONT`] (Sora Regular, ~50 KB,
/// OFL 1.1) when nothing matches or the file cannot be read. Always
/// returns usable bytes so canvas construction never panics on a
/// system without the expected fonts.
pub( super ) fn load_default_font_bytes() -> Vec<u8>
{
for path in SYSTEM_FONT_CANDIDATES.iter()
{
if std::path::Path::new( path ).exists()
{
if let Ok( bytes ) = std::fs::read( path )
{
return bytes;
}
}
}
crate::theme::fallback::FALLBACK_FONT.to_vec()
}

168
src/gles_render/image.rs Normal file
View File

@@ -0,0 +1,168 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Raster-image draw path for [`GlesCanvas`]. Uploads the RGBA
//! bytes as a premultiplied-alpha texture (cached by content
//! fingerprint so repeated draws of the same buffer do not
//! re-upload) and composites it through the texture shader,
//! honouring the canvas' `global_alpha` via the opacity uniform.
//!
//! The cache is keyed by `(size, fingerprint)` where the fingerprint
//! is a 64-bit hash sampled from the RGBA bytes. This avoids the
//! address-reuse trap of a pointer-based key — when an `Arc<Vec<u8>>`
//! gets dropped and the allocator hands the same heap address to a
//! *different* buffer on the next frame, a pointer-keyed cache would
//! serve the stale texture for the new content. Content-keying makes
//! that impossible: identical bytes → identical key, regardless of
//! where they live in memory.
use std::collections::hash_map::DefaultHasher;
use std::hash::{ Hash, Hasher };
use glow::HasContext;
use crate::types::Rect;
use super::helpers::{ ortho_rect, upload_rgba_texture };
use super::GlesCanvas;
/// Compute a 64-bit fingerprint of an RGBA buffer for the texture
/// cache. Hashes the full byte slice for small buffers (icons,
/// thumbnails — below 16 KB ≈ 64×64 RGBA), and falls back to a
/// strided 8 × 512-byte sample for anything larger so a wallpaper
/// blit does not pay an 8 MB hash on every frame. Both modes
/// distinguish the chevron-icon-style cases that motivated the move
/// to content-keying — the SVG-rasterised buffers differ across
/// most of their interior bytes, not just at the corners.
fn fingerprint_rgba( bytes: &[u8] ) -> u64
{
const FULL_HASH_THRESHOLD: usize = 16 * 1024;
const SAMPLE_CHUNKS: usize = 8;
const SAMPLE_CHUNK_BYTES: usize = 512;
let mut h = DefaultHasher::new();
let n = bytes.len();
n.hash( &mut h );
if n <= FULL_HASH_THRESHOLD
{
bytes.hash( &mut h );
} else {
let stride = n / SAMPLE_CHUNKS;
for i in 0..SAMPLE_CHUNKS
{
let pos = ( i * stride ).min( n - SAMPLE_CHUNK_BYTES );
bytes[ pos..pos + SAMPLE_CHUNK_BYTES ].hash( &mut h );
}
}
h.finish()
}
impl GlesCanvas
{
/// Blit RGBA image data scaled to dest rect with opacity.
///
/// Defensive: rejects buffers whose declared `img_w × img_h × 4` does not
/// match `rgba_data.len()`. The mismatch path logs a one-line warning
/// and returns without uploading or drawing — the same boundary that
/// the internal `upload_rgba_texture` helper enforces, raised one
/// level so the cache key is never seeded with a bogus mapping.
pub fn draw_image_data( &mut self, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 )
{
let expected = ( img_w as usize ).saturating_mul( img_h as usize ).saturating_mul( 4 );
if img_w == 0 || img_h == 0 || rgba_data.len() != expected
{
eprintln!(
"[ltk] GlesCanvas::draw_image_data: refusing draw — {}×{} declared, {} bytes provided, expected {}",
img_w, img_h, rgba_data.len(), expected,
);
return;
}
self.activate_target();
// Content-fingerprint key — see the module doc for the
// rationale. The (w, h) prefix means a pathological pair of
// buffers with identical bytes but different declared sizes
// stays distinct (cannot happen for valid input, defence in
// depth).
let cache_key = ( img_w, img_h, fingerprint_rgba( rgba_data ) );
if !self.image_cache.contains_key( &cache_key )
{
let tex = upload_rgba_texture( &self.gl, self.version, rgba_data, img_w as i32, img_h as i32 );
self.image_cache.insert( cache_key, ( tex, img_w, img_h ) );
}
// Snap to integer pixels. With GL_LINEAR sampling, a
// fractional `dest.x` / `dest.y` makes every fragment sample
// at sub-texel offset — bilinear blends adjacent texels and
// the result reads as ~1 px softer than the source. At
// integer offset every fragment center maps to a texel
// centre and the bilinear collapses to identity, so a 1:1
// sampled icon renders crisp.
let dest = Rect
{
x: dest.x.round(),
y: dest.y.round(),
width: dest.width.round(),
height: dest.height.round(),
};
if let Some( ( tex, _, _ ) ) = self.image_cache.get( &cache_key )
{
let mvp = ortho_rect( self.width, self.height, dest );
let alpha = opacity * self.global_alpha;
// SAFETY: see `primitives.rs` module doc. `*tex` is owned by
// `self.image_cache` so it outlives the call. The image cache
// stays valid as long as `&mut self` is held — no eviction
// path runs concurrently with the draw.
unsafe
{
self.gl.use_program( Some( self.tex_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_tex_mvp ), false, &mvp );
self.gl.uniform_1_f32( Some( &self.u_tex_opacity ), alpha );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( *tex ) );
self.gl.uniform_1_i32( Some( &self.u_tex_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 );
}
}
}
/// Draw an externally-owned GL texture into `dest`.
///
/// The caller owns the texture and is responsible for keeping it valid
/// for the duration of this call. No upload, no caching — used to
/// composite content rendered by another GL producer (web engine,
/// video decoder, …) into the LTK widget tree.
pub fn draw_external_texture( &mut self, texture: glow::Texture, dest: Rect, opacity: f32 )
{
self.activate_target();
let dest = Rect
{
x: dest.x.round(),
y: dest.y.round(),
width: dest.width.round(),
height: dest.height.round(),
};
let mvp = ortho_rect( self.width, self.height, dest );
let alpha = opacity * self.global_alpha;
// SAFETY: caller-owned texture must outlive this call. We only
// sample it; we never delete or reassign the GL name.
unsafe
{
self.gl.use_program( Some( self.tex_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_tex_mvp ), false, &mvp );
self.gl.uniform_1_f32( Some( &self.u_tex_opacity ), alpha );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( texture ) );
self.gl.uniform_1_i32( Some( &self.u_tex_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 );
}
}
}

387
src/gles_render/mod.rs Normal file
View File

@@ -0,0 +1,387 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! GPU-accelerated rendering backend using EGL + GLES2 / GLES3.
//!
//! Mirrors the public surface of the software backend so that
//! [`crate::core::Canvas`] can route widget draw calls to either backend
//! by `match self`. The EGL context bootstrap lives in
//! [`crate::egl_context`] — this module is just the renderer that runs
//! once a context is current.
//!
//! 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.
//!
//! # Submodule layout
//!
//! * `setup` — `GlesCanvas::{new, sub_canvas, resize, set_font_registry,
//! font_for, dpi/alpha accessors}`.
//! * `framebuffer` — `GlesCanvas::{blit, present, activate_target,
//! borrowed_texture, read_rgba_pixels, ensure_aux_*, snapshot_fbo_region,
//! fill_backdrop, invalidate_aux}` — everything that manipulates the
//! FBO or auxiliary snapshot textures.
//! * `clip` — `GlesCanvas::{set_clip_rects, clear_clip, set_scissor,
//! scissor_pixels, fill, clear, clear_rects_transparent}`.
//! * `primitives` — `GlesCanvas::{fill_rect, fill_linear_gradient_rect,
//! fill_radial_gradient_rect, fill_shadow_outer, fill_shadow_inset,
//! stroke_rect, draw_line}`.
//! * `text` — `GlesCanvas::{draw_text, measure_text, draw_glyph_texture}`.
//! * `image` — `GlesCanvas::draw_image_data`.
//! * `shaders` — GLSL ES 1.00 shader sources (const strings).
//! * `helpers` — free functions: `ortho_rect`, `compile_program`,
//! `alloc_fbo_tex`, `upload_*_texture`, handle extractors,
//! `find_font` + `SYSTEM_FONT_CANDIDATES`.
//! * `raii` — `FboBinding` / `ProgramBinding` scoped guards for the
//! handful of operations that change global GL state for a scope
//! and must guarantee restoration even on early return / panic
//! (presently only `present`). The renderer otherwise relies on
//! `activate_target`'s lazy re-bind at the entry of each draw
//! method — see the module's own doc for when to use the guards
//! and when not to.
use std::collections::HashMap;
use std::sync::Arc;
use fontdue::Font;
use glow::HasContext;
use crate::theme::FontRegistry;
use crate::types::Rect;
pub( crate ) mod shaders;
pub( crate ) mod helpers;
pub( crate ) mod setup;
pub( crate ) mod framebuffer;
pub( crate ) mod clip;
pub( crate ) mod primitives;
pub( crate ) mod text;
pub( crate ) mod image;
mod raii;
// ─── Public types ────────────────────────────────────────────────────────────
/// Which GLES profile the active context is. Stored so per-frame
/// fast-paths can be selected without re-querying GL.
#[ derive( Clone, Copy, PartialEq, Eq, Debug ) ]
pub enum GlesVersion
{
V2,
V3,
}
/// Borrowed view of the texture backing a [`GlesCanvas`].
///
/// The texture and framebuffer remain owned by ltk. Consumers may
/// sample the texture while the canvas is alive, but must not delete
/// or take ownership of the GL names. A resize can replace both
/// names, so callers should query this after rendering/resizing, not
/// cache it indefinitely.
#[ derive( Clone, Copy, Debug ) ]
pub struct BorrowedGlesTexture
{
/// Native GL texture name for the canvas color attachment.
pub texture_id: u32,
/// Native GL framebuffer name that owns `texture_id` as color attachment 0.
pub framebuffer_id: u32,
/// Glow texture handle for callers already using glow.
pub texture: glow::Texture,
/// Glow framebuffer handle for callers already using glow.
pub framebuffer: glow::Framebuffer,
/// Texture width in physical pixels.
pub width: u32,
/// Texture height in physical pixels.
pub height: u32,
/// The texture contains RGBA pixels with premultiplied alpha.
pub premultiplied: bool,
/// Whether consumers should treat the texture as vertically inverted.
pub y_inverted: bool,
}
/// Cached glyph: pre-rasterized bitmap uploaded as a GL texture.
pub ( super ) struct GlyphEntry
{
pub ( super ) texture: glow::Texture,
pub ( super ) metrics: fontdue::Metrics,
pub ( super ) tex_w: i32,
pub ( super ) tex_h: i32,
}
// ─── GlesCanvas ──────────────────────────────────────────────────────────────
/// GPU-accelerated canvas using EGL + GLES2/3.
///
/// Renders into a persistent FBO (the "shadow canvas") so widget
/// pixels survive across frames — this mirrors the software pixmap
/// model and is what enables the partial-redraw path on the GPU side.
/// `Self::present` blits the FBO onto the default framebuffer; the
/// caller is responsible for the `eglSwapBuffers` that follows.
pub struct GlesCanvas
{
pub gl: Arc<glow::Context>,
pub version: GlesVersion,
/// Default font loaded from the system via `helpers::find_font`.
/// Kept as a fallback for callers that do not route through the
/// theme registry.
pub font: Arc<Font>,
/// Optional theme font registry. Populated by the runtime after
/// theme load; until then it is `None` and [`Self::font_for`]
/// falls back to [`Self::font`].
pub font_registry: Option<Arc<FontRegistry>>,
pub dpi_scale: f32,
pub global_alpha: f32,
pub width: u32,
pub height: u32,
// Shader programs. `Program` is a Copy handle; sub-canvases share
// these with their parent (no reference counting needed since they
// outlive the process).
rect_program: glow::Program,
tex_program: glow::Program,
glyph_program: glow::Program,
blit_program: glow::Program,
sub_blit_program: glow::Program,
linear_gradient_program: glow::Program,
radial_gradient_program: glow::Program,
shadow_outer_program: glow::Program,
shadow_inset_program: glow::Program,
// Shared geometry (a unit quad as two triangles)
quad_vao: glow::VertexArray,
_quad_vbo: glow::Buffer,
// Uniform locations for rect shader
u_rect_mvp: glow::UniformLocation,
u_rect_color: glow::UniformLocation,
u_rect_size: glow::UniformLocation,
u_rect_radii: glow::UniformLocation,
u_rect_stroke: glow::UniformLocation,
u_rect_pad: glow::UniformLocation,
// Uniform locations for texture shader
u_tex_mvp: glow::UniformLocation,
u_tex_opacity: glow::UniformLocation,
u_tex_sampler: glow::UniformLocation,
// Uniform locations for glyph shader
u_glyph_mvp: glow::UniformLocation,
u_glyph_color: glow::UniformLocation,
u_glyph_opacity: glow::UniformLocation,
u_glyph_sampler: glow::UniformLocation,
// Uniform location for blit shader
u_blit_sampler: glow::UniformLocation,
// Uniform locations for sub-canvas blit shader
u_subblit_mvp: glow::UniformLocation,
u_subblit_sampler: glow::UniformLocation,
u_subblit_opacity: glow::UniformLocation,
u_subblit_fade_bottom: glow::UniformLocation,
u_subblit_height_px: glow::UniformLocation,
// Uniform locations for the linear gradient shader
u_lingrad_mvp: glow::UniformLocation,
u_lingrad_lut: glow::UniformLocation,
u_lingrad_dir: glow::UniformLocation,
u_lingrad_size: glow::UniformLocation,
u_lingrad_line_length: glow::UniformLocation,
u_lingrad_radii: glow::UniformLocation,
u_lingrad_pad: glow::UniformLocation,
u_lingrad_lut_domain_min: glow::UniformLocation,
u_lingrad_lut_domain_span: glow::UniformLocation,
// Uniform locations for the radial gradient shader
u_radgrad_mvp: glow::UniformLocation,
u_radgrad_lut: glow::UniformLocation,
u_radgrad_center: glow::UniformLocation,
u_radgrad_radius_frac: glow::UniformLocation,
u_radgrad_size: glow::UniformLocation,
u_radgrad_radii: glow::UniformLocation,
u_radgrad_pad: glow::UniformLocation,
u_radgrad_lut_domain_min: glow::UniformLocation,
u_radgrad_lut_domain_span: glow::UniformLocation,
// Uniform locations for the outer shadow shader
u_shadow_mvp: glow::UniformLocation,
u_shadow_size: glow::UniformLocation,
u_shadow_padding: glow::UniformLocation,
u_shadow_radii: glow::UniformLocation,
u_shadow_spread: glow::UniformLocation,
u_shadow_sigma: glow::UniformLocation,
u_shadow_color: glow::UniformLocation,
// Uniform locations for the inner (inset) shadow shader
u_inset_mvp: glow::UniformLocation,
u_inset_size: glow::UniformLocation,
u_inset_padding: glow::UniformLocation,
u_inset_radii: glow::UniformLocation,
u_inset_spread: glow::UniformLocation,
u_inset_sigma: glow::UniformLocation,
u_inset_offset: glow::UniformLocation,
u_inset_color: glow::UniformLocation,
/// Inset-shadow shader variant for `BlendMode::Overlay`. Distinct
/// from [`Self::shadow_inset_program`] because CSS Overlay cannot
/// be expressed with fixed-function blend — this shader samples
/// the just-snapshotted FBO content (via `aux_a`) and
/// computes the per-channel Overlay formula in-shader, then
/// outputs premultiplied.
shadow_inset_overlay_program: glow::Program,
u_inset_ov_mvp: glow::UniformLocation,
u_inset_ov_size: glow::UniformLocation,
u_inset_ov_padding: glow::UniformLocation,
u_inset_ov_radii: glow::UniformLocation,
u_inset_ov_spread: glow::UniformLocation,
u_inset_ov_sigma: glow::UniformLocation,
u_inset_ov_offset: glow::UniformLocation,
u_inset_ov_color: glow::UniformLocation,
u_inset_ov_snapshot: glow::UniformLocation,
u_inset_ov_canvas_size: glow::UniformLocation,
/// Horizontal pass of the separable Gaussian used by
/// [`Self::fill_backdrop`](framebuffer). Samples
/// `aux_a` (snapshot of the main FBO) and writes the
/// horizontally-blurred result into `aux_b`.
backdrop_blur_h_program: glow::Program,
u_bd_h_source: glow::UniformLocation,
u_bd_h_texel: glow::UniformLocation,
u_bd_h_canvas_size: glow::UniformLocation,
u_bd_h_sigma: glow::UniformLocation,
/// Vertical pass of the separable Gaussian combined with the SDF
/// clip to the surface shape and optional tint. Reads
/// `aux_b` (H-blurred) and writes to the main FBO at the
/// surface rect.
backdrop_composite_program: glow::Program,
u_bd_c_mvp: glow::UniformLocation,
u_bd_c_source: glow::UniformLocation,
u_bd_c_canvas_size: glow::UniformLocation,
u_bd_c_texel: glow::UniformLocation,
u_bd_c_sigma: glow::UniformLocation,
u_bd_c_size: glow::UniformLocation,
u_bd_c_padding: glow::UniformLocation,
u_bd_c_radii: glow::UniformLocation,
u_bd_c_tint: glow::UniformLocation,
/// Fast (low-quality) horizontal Gaussian. Same role as
/// `backdrop_blur_h_program` but with a 9-tap kernel
/// (`RADIUS = 4`) instead of 41 taps. Used during animations /
/// drags via [`crate::render::low_quality_paint`].
backdrop_fast_blur_h_program: glow::Program,
u_bd_fh_source: glow::UniformLocation,
u_bd_fh_texel: glow::UniformLocation,
u_bd_fh_canvas_size: glow::UniformLocation,
u_bd_fh_sigma: glow::UniformLocation,
/// Fast (low-quality) vertical + SDF + tint composite. 9-tap
/// kernel counterpart to `backdrop_composite_program`.
backdrop_fast_composite_program: glow::Program,
u_bd_fc_mvp: glow::UniformLocation,
u_bd_fc_source: glow::UniformLocation,
u_bd_fc_canvas_size: glow::UniformLocation,
u_bd_fc_texel: glow::UniformLocation,
u_bd_fc_sigma: glow::UniformLocation,
u_bd_fc_size: glow::UniformLocation,
u_bd_fc_padding: glow::UniformLocation,
u_bd_fc_radii: glow::UniformLocation,
u_bd_fc_tint: glow::UniformLocation,
// Glyph cache: (char, size_key, font_id) → GlyphEntry. The
// `font_id` is the address of the `Arc<Font>` used for the
// rasterisation, so distinct weights / families of the same
// (char, size) keep separate atlas entries.
glyph_cache: HashMap<(char, u32, usize), GlyphEntry>,
// Reusable texture cache for images. Keyed by
// `(width, height, content fingerprint)` rather than the source
// buffer's heap address — pointer-keying produced ghosting when
// short-lived `Arc<Vec<u8>>` buffers got dropped and the
// allocator handed the same address to a different buffer next
// frame (the cache would happily serve the stale texture).
// Content-keying tolerates that case at the cost of one
// `DefaultHasher` pass over the bytes per draw call — fast for
// any reasonable icon size.
image_cache: HashMap<(u32, u32, u64), (glow::Texture, u32, u32)>,
// Gradient LUT cache: FNV-ish hash of the 512×RGBA8 LUT bytes → texture.
// Gradients are theme-derived and constant across frames; caching avoids
// a glTexImage2D round-trip (create + upload + delete) on every draw call.
// Cleared via `clear_gradient_cache()` on theme changes.
gradient_lut_cache: HashMap<u64, glow::Texture>,
/// Current scissor: `Some(rect)` when a clip is installed
/// (GL_SCISSOR_TEST is enabled), `None` when cleared.
clip_scissor: Option<Rect>,
/// Persistent shadow framebuffer. All draw methods bind this;
/// [`Self::present`](framebuffer) is the only call that switches
/// to the default framebuffer.
fbo: glow::Framebuffer,
/// Color attachment for `fbo`. Reallocated on resize.
fbo_tex: glow::Texture,
/// Auxiliary FBO + texture used as a snapshot of `fbo`
/// for framebuffer-fetch-style effects (CSS `Overlay` blend,
/// backdrop blur). Lazily allocated on first use; dropped on
/// resize so the next user re-allocates at the new size.
aux_a: Option<( glow::Framebuffer, glow::Texture )>,
/// Second auxiliary FBO, used as ping-pong target for the
/// separable Gaussian blur in backdrop compositing. Uses LINEAR
/// filtering for the V-pass bilinear sampling (vs `aux_a`'s
/// NEAREST).
aux_b: Option<( glow::Framebuffer, glow::Texture )>,
}
// ─── Drop ────────────────────────────────────────────────────────────────────
/// Free this canvas's owned GL resources: FBO, color attachment, aux
/// FBOs if allocated, and any cached glyph / image textures. Shader
/// programs and the quad VAO/VBO are shared with sub-canvases and
/// intentionally NOT deleted here — they leak at process exit, which
/// is fine for a process-wide GL context.
impl Drop for GlesCanvas
{
fn drop( &mut self )
{
// SAFETY: every handle freed below was created through `self.gl` —
// either in `setup.rs::new` / `sub_canvas` (`fbo`, `fbo_tex`),
// `framebuffer.rs::ensure_aux_a` / `ensure_aux_b` (`aux_a`, `aux_b`),
// `text.rs::draw_text` (`glyph_cache`), `image.rs::draw_image_data`
// (`image_cache`), or `primitives.rs::ensure_lut_texture`
// (`gradient_lut_cache`). Each container `drain` / `take` is
// consumed once so no double-free is possible. Caller must keep
// the GL context current at drop time — this is documented on
// `core::UiSurface::from_current_gles_loader` and
// `from_canvas_with_egl_context`.
unsafe
{
self.gl.delete_framebuffer( self.fbo );
self.gl.delete_texture( self.fbo_tex );
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 );
}
for ( _, entry ) in self.glyph_cache.drain()
{
self.gl.delete_texture( entry.texture );
}
for ( _, ( tex, _, _ ) ) in self.image_cache.drain()
{
self.gl.delete_texture( tex );
}
for ( _, tex ) in self.gradient_lut_cache.drain()
{
self.gl.delete_texture( tex );
}
}
}
}

View File

@@ -0,0 +1,545 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Primitive draw ops for [`GlesCanvas`]: solid and gradient rect
//! fills, inner and outer shadows, stroke, line. All go through the
//! shared quad VAO + one of the pre-compiled shader programs from
//! [`super::shaders`], with uniforms set per-call.
//!
//! # Shared `unsafe` invariants
//!
//! Every `unsafe` block below relies on the same canvas-wide contract
//! and only adds one note per block when something specific applies:
//!
//! * The GL context behind `self.gl` is current on this thread — the
//! `GlesCanvas` constructors only return a value when this is true,
//! and every `&mut self` method runs on the construction thread.
//! * Every `program` / `uniform_*` / `vertex_array` / `texture` handle
//! stored on `self` was produced by the same context in `setup.rs`
//! and outlives the draw call.
//! * Each draw method calls `activate_target` first, which re-binds the
//! canvas FBO and re-applies viewport / scissor — so the unsafe block
//! never inherits a stranded binding from a sibling canvas.
//! * `bind_vertex_array(None)` and `bind_texture(_, None)` at the end
//! of the unsafe block leaves the global GL state in the same shape
//! the next draw method assumes (no stranded VAO / texture binding).
use glow::HasContext;
use crate::theme::{ gradient_lut, BlendMode, InsetShadow, LinearGradient, RadialGradient, Shadow };
use crate::types::{ Color, Corners, Rect };
use super::helpers::ortho_rect;
use super::GlesCanvas;
impl GlesCanvas
{
/// Returns `true` when `rect` (expanded by `margin` on every side)
/// is entirely outside the active scissor — the GPU would cull
/// every fragment pre-shader anyway, so skipping the draw saves
/// the `activate_target` / `use_program` / uniform / VAO / draw
/// sequence. No scissor = no cull (the whole canvas is fair game).
fn rect_culled( &self, rect: Rect, margin: f32 ) -> bool
{
let Some( clip ) = self.clip_scissor else { return false };
let r_x0 = rect.x - margin;
let r_y0 = rect.y - margin;
let r_x1 = rect.x + rect.width + margin;
let r_y1 = rect.y + rect.height + margin;
let c_x1 = clip.x + clip.width;
let c_y1 = clip.y + clip.height;
r_x1 <= clip.x || c_x1 <= r_x0 || r_y1 <= clip.y || c_y1 <= r_y0
}
pub fn fill_rect( &mut self, rect: Rect, color: Color, corners: Corners )
{
if self.rect_culled( rect, 1.0 ) { return; }
self.activate_target();
// Expand the quad 1 px on each side so the outer half of the SDF
// antialiasing band (d ∈ [0, 0.5]) has fragments to cover along the
// straight edges of pills / rounded rects. `u_size` and `u_radii`
// stay anchored to the original rect — `u_pad` lets the shader
// remap `v_uv` from the larger quad back into rect-local space.
let pad = 1.0_f32;
let expanded = Rect
{
x: rect.x - pad,
y: rect.y - pad,
width: rect.width + 2.0 * pad,
height: rect.height + 2.0 * pad,
};
let mvp = ortho_rect( self.width, self.height, expanded );
let alpha = color.a * self.global_alpha;
// SAFETY: see module doc. `u_rect_stroke = 0.0` triggers the fill
// branch of `RECT_FRAG_SRC`; the SDF reads `u_size` / `u_radii` of
// the original rect while `u_pad` remaps `v_uv` from the expanded
// quad — so the rasteriser sees the padded geometry but the shader
// computes coverage in original-rect coordinates.
unsafe
{
self.gl.use_program( Some( self.rect_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_rect_mvp ), false, &mvp );
self.gl.uniform_4_f32( Some( &self.u_rect_color ), color.r, color.g, color.b, alpha );
self.gl.uniform_2_f32( Some( &self.u_rect_size ), rect.width, rect.height );
self.gl.uniform_4_f32_slice( Some( &self.u_rect_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_rect_stroke ), 0.0 );
self.gl.uniform_1_f32( Some( &self.u_rect_pad ), pad );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
}
}
/// Return the cached gradient LUT texture for `lut_bytes`, uploading
/// it on the first call for each unique byte sequence. Subsequent calls
/// with the same bytes skip `glTexImage2D` entirely. The texture lives
/// until `clear_gradient_cache` is called (e.g. on theme change) or
/// the canvas is dropped.
fn ensure_lut_texture( &mut self, lut_bytes: &[u8] ) -> glow::Texture
{
use std::hash::{ Hash, Hasher };
let mut h = std::collections::hash_map::DefaultHasher::new();
lut_bytes.hash( &mut h );
let key = h.finish();
if let Some( &tex ) = self.gradient_lut_cache.get( &key )
{
return tex;
}
// SAFETY: see module doc. `lut_bytes` is the contiguous
// `LUT_SAMPLES * 4` byte LUT produced by `gradient_lut::build_lut_bytes`
// (RGBA8, one row); the texture allocation matches that exact shape.
// We unbind TEXTURE_2D on exit to keep the unit-0 binding shape the
// rest of the canvas assumes.
unsafe
{
let tex = self.gl.create_texture().expect( "gradient LUT texture" );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
self.gl.tex_image_2d(
glow::TEXTURE_2D,
0,
glow::RGBA as i32,
gradient_lut::LUT_SAMPLES as i32,
1,
0,
glow::RGBA,
glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice( Some( lut_bytes ) ),
);
self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::LINEAR as i32 );
self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32 );
self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 );
self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 );
self.gl.bind_texture( glow::TEXTURE_2D, None );
self.gradient_lut_cache.insert( key, tex );
tex
}
}
/// Fill a rectangle with a linear gradient.
///
/// Bakes a CPU-side LUT from `g.stops` and fetches (or creates) the
/// corresponding cached GPU texture via `ensure_lut_texture`,
/// then draws the quad with the gradient shader.
pub fn fill_linear_gradient_rect( &mut self, rect: Rect, g: &LinearGradient, corners: Corners )
{
if self.rect_culled( rect, 1.0 ) { return; }
let lut_bytes = gradient_lut::build_lut_bytes( &g.stops, g.space );
let tex = self.ensure_lut_texture( &lut_bytes );
let theta = g.angle_deg.to_radians();
// CSS convention: 0° points up. dir.y is negative-up in screen space.
let dir_x = theta.sin();
let dir_y = -theta.cos();
let line_length = ( rect.width * dir_x ).abs() + ( rect.height * dir_y ).abs();
let line_length = if line_length.abs() < 1e-3 { 1e-3 } else { line_length };
self.activate_target();
// See fill_rect for the rationale on the 1 px quad pad.
let pad = 1.0_f32;
let expanded = Rect
{
x: rect.x - pad,
y: rect.y - pad,
width: rect.width + 2.0 * pad,
height: rect.height + 2.0 * pad,
};
let mvp = ortho_rect( self.width, self.height, expanded );
// SAFETY: see module doc. `tex` is the cached LUT for `g.stops`
// produced by `ensure_lut_texture` above (RGBA8, sampler unit 0);
// `dir_x`, `dir_y`, `line_length` are derived from finite inputs
// (`line_length` is clamped above 1e-3 so the shader's divide is
// well-defined).
unsafe
{
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
self.gl.use_program( Some( self.linear_gradient_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_lingrad_mvp ), false, &mvp );
self.gl.uniform_1_i32( Some( &self.u_lingrad_lut ), 0 );
self.gl.uniform_2_f32( Some( &self.u_lingrad_dir ), dir_x, dir_y );
self.gl.uniform_2_f32( Some( &self.u_lingrad_size ), rect.width, rect.height );
self.gl.uniform_1_f32( Some( &self.u_lingrad_line_length ), line_length );
self.gl.uniform_4_f32_slice( Some( &self.u_lingrad_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_lingrad_pad ), pad );
self.gl.uniform_1_f32( Some( &self.u_lingrad_lut_domain_min ), gradient_lut::LUT_DOMAIN.0 );
self.gl.uniform_1_f32( Some( &self.u_lingrad_lut_domain_span ), gradient_lut::LUT_DOMAIN.1 - gradient_lut::LUT_DOMAIN.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 );
}
}
/// Fill a rectangle with a radial gradient.
///
/// `g.center` is interpreted in box-relative fractions (as declared by
/// the theme), `g.radius` is the fractional radial extent. Same cached
/// LUT strategy as [`Self::fill_linear_gradient_rect`].
pub fn fill_radial_gradient_rect( &mut self, rect: Rect, g: &RadialGradient, corners: Corners )
{
if self.rect_culled( rect, 1.0 ) { return; }
let lut_bytes = gradient_lut::build_lut_bytes( &g.stops, g.space );
let tex = self.ensure_lut_texture( &lut_bytes );
self.activate_target();
// See fill_rect for the rationale on the 1 px quad pad.
let pad = 1.0_f32;
let expanded = Rect
{
x: rect.x - pad,
y: rect.y - pad,
width: rect.width + 2.0 * pad,
height: rect.height + 2.0 * pad,
};
let mvp = ortho_rect( self.width, self.height, expanded );
// SAFETY: see module doc. Same LUT contract as the linear path
// above. `g.center` and `g.radius` are finite fractional values
// from the theme parser (validated at load time).
unsafe
{
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
self.gl.use_program( Some( self.radial_gradient_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_radgrad_mvp ), false, &mvp );
self.gl.uniform_1_i32( Some( &self.u_radgrad_lut ), 0 );
self.gl.uniform_2_f32( Some( &self.u_radgrad_center ), g.center[0], g.center[1] );
self.gl.uniform_1_f32( Some( &self.u_radgrad_radius_frac ), g.radius );
self.gl.uniform_2_f32( Some( &self.u_radgrad_size ), rect.width, rect.height );
self.gl.uniform_4_f32_slice( Some( &self.u_radgrad_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_radgrad_pad ), pad );
self.gl.uniform_1_f32( Some( &self.u_radgrad_lut_domain_min ), gradient_lut::LUT_DOMAIN.0 );
self.gl.uniform_1_f32( Some( &self.u_radgrad_lut_domain_span ), gradient_lut::LUT_DOMAIN.1 - gradient_lut::LUT_DOMAIN.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 );
}
}
/// Paint an outer drop shadow behind a rounded rect.
///
/// Analytic Gaussian approximation over the shape SDF — see the note
/// above `SHADOW_OUTER_FRAG_SRC`. The drawing quad is expanded on each
/// side by `max(blur, 0) + max(spread, 0) + 1` (the `+ 1` leaves a
/// single antialias pixel of slack) and offset by `shadow.offset` so
/// the fragment shader sees the full falloff region.
///
/// Only `BlendMode::Normal` is honoured today; other modes silently
/// fall through to `Normal` because the analytic shader only outputs
/// `over`.
pub fn fill_shadow_outer( &mut self, target: Rect, shadow: &Shadow, corners: Corners )
{
let blur_margin = shadow.blur.max( 0.0 );
let spread_margin = shadow.spread.max( 0.0 );
let margin = blur_margin + spread_margin + 1.0;
// Outer shadows draw a quad expanded by `margin` on each side
// (to capture the Gaussian falloff outside the shape); offset
// the target by `shadow.offset` for the cull test so a shadow
// that sits off-centre is not skipped prematurely.
let culled_rect = Rect
{
x: target.x + shadow.offset[0],
y: target.y + shadow.offset[1],
width: target.width,
height: target.height,
};
if self.rect_culled( culled_rect, margin ) { return; }
let quad = Rect
{
x: target.x + shadow.offset[0] - margin,
y: target.y + shadow.offset[1] - margin,
width: target.width + 2.0 * margin,
height: target.height + 2.0 * margin,
};
let sigma = shadow.sigma().max( 0.5 );
let alpha = shadow.color.a * self.global_alpha;
self.activate_target();
let mvp = ortho_rect( self.width, self.height, quad );
// SAFETY: see module doc. `sigma` is clamped above 0.5 so the
// shader's divide is well-defined; `margin` covers the full
// Gaussian falloff so the rasteriser sees every fragment the
// SDF wants to shade.
unsafe
{
self.gl.use_program( Some( self.shadow_outer_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_shadow_mvp ), false, &mvp );
self.gl.uniform_2_f32( Some( &self.u_shadow_size ), target.width, target.height );
self.gl.uniform_2_f32( Some( &self.u_shadow_padding ), margin, margin );
self.gl.uniform_4_f32_slice( Some( &self.u_shadow_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_shadow_spread ), shadow.spread );
self.gl.uniform_1_f32( Some( &self.u_shadow_sigma ), sigma );
self.gl.uniform_4_f32( Some( &self.u_shadow_color ),
shadow.color.r, shadow.color.g, shadow.color.b, alpha );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
}
}
/// Paint an inner (inset) shadow inside a rounded rect.
///
/// Differences versus [`Self::fill_shadow_outer`]:
///
/// * The drawing quad matches the target rect exactly — the inset is
/// clipped to the outer SDF by the shader, so no external padding
/// is needed and there is no spatial offset of the geometry.
/// * The shader carries the per-shadow `offset` as a uniform rather
/// than translating the quad, because the inset is biased *inside*
/// the shape rather than cast outside it.
/// * The pipeline blend state is switched for the duration of the
/// draw to honour `InsetShadow::blend` and restored afterwards.
///
/// Blend modes: `Normal` stays on the pipeline default
/// `(ONE, ONE_MINUS_SRC_ALPHA)`. `PlusLighter` uses `(ONE, ONE)` —
/// pure additive on premultiplied inputs, naturally clamped by the
/// framebuffer to `[0, 1]`, which is exactly the CSS definition.
/// `Multiply` uses `(DST_COLOR, ZERO)` on RGB and `(DST_ALPHA, ZERO)`
/// on alpha — a straight multiplicative blend. `Screen` uses
/// `(ONE_MINUS_DST_COLOR, ONE)`, the canonical `a + b a·b` form.
/// `Overlay` cannot be expressed with GL's fixed-function blend
/// state alone — it needs to read the destination pixel. This
/// branch snapshots the current FBO into `aux_a` via
/// `glCopyTexSubImage2D`, then draws through
/// `shadow_inset_overlay_program` which samples that snapshot at
/// `gl_FragCoord.xy / canvas_size`, computes the per-channel CSS
/// Overlay formula in-shader, and emits premultiplied
/// `(overlay * mask, mask)` — so the usual premul over blend
/// composes the blended colour on top of the base. One FBO
/// snapshot per Overlay shadow.
pub fn fill_shadow_inset( &mut self, target: Rect, shadow: &InsetShadow, corners: Corners )
{
// Inset shadows draw a quad at `target` ± 1 px AA pad; the
// shape lives entirely inside. If that quad is outside the
// scissor, every fragment is culled — skip the whole path
// (including the Overlay snapshot, which is the expensive
// bit).
if self.rect_culled( target, 1.0 ) { return; }
let sigma = shadow.sigma().max( 0.5 );
let alpha = shadow.color.a * self.global_alpha;
// Overlay goes through the framebuffer-fetch path. Everything
// else uses the original SDF inset shader with a blend-state
// swap.
if matches!( shadow.blend, BlendMode::Overlay )
{
// Snapshot the inset's draw rect plus the 1 px AA pad so the
// quad's expanded edge still samples valid snapshot data.
// The shader samples `aux_a` at `gl_FragCoord.xy /
// canvas_size`, so reads outside the snapshotted region
// would pull stale content from a previous frame.
//
// Use the scissor-tight variant: Overlay samples at exactly
// one point per fragment, and any fragment outside the
// active scissor is culled before the shader runs, so the
// snapshot only needs to cover the intersection.
let pad = 1.0_f32;
let snap_rect = Rect
{
x: target.x - pad,
y: target.y - pad,
width: target.width + 2.0 * pad,
height: target.height + 2.0 * pad,
};
self.snapshot_fbo_region_tight( snap_rect );
self.activate_target();
let expanded = Rect
{
x: target.x - pad,
y: target.y - pad,
width: target.width + 2.0 * pad,
height: target.height + 2.0 * pad,
};
let mvp = ortho_rect( self.width, self.height, expanded );
let aux_tex = self.aux_a.expect( "snapshotted" ).1;
// SAFETY: see module doc. `aux_tex` was just populated by
// `snapshot_fbo_region_tight` so it carries a valid copy of
// the live FBO at full canvas resolution; the shader samples
// it through `gl_FragCoord.xy / canvas_size`. We unbind unit-0
// after the draw to avoid stranding the snapshot binding.
unsafe
{
self.gl.use_program( Some( self.shadow_inset_overlay_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_inset_ov_mvp ), false, &mvp );
self.gl.uniform_2_f32( Some( &self.u_inset_ov_size ), target.width, target.height );
self.gl.uniform_2_f32( Some( &self.u_inset_ov_padding ), pad, pad );
self.gl.uniform_4_f32_slice( Some( &self.u_inset_ov_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_inset_ov_spread ), shadow.spread );
self.gl.uniform_1_f32( Some( &self.u_inset_ov_sigma ), sigma );
self.gl.uniform_2_f32( Some( &self.u_inset_ov_offset ), shadow.offset[0], shadow.offset[1] );
self.gl.uniform_4_f32( Some( &self.u_inset_ov_color ),
shadow.color.r, shadow.color.g, shadow.color.b, alpha );
self.gl.uniform_2_f32( Some( &self.u_inset_ov_canvas_size ), self.width as f32, self.height as f32 );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( aux_tex ) );
self.gl.uniform_1_i32( Some( &self.u_inset_ov_snapshot ), 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 );
}
return;
}
self.activate_target();
// 1 px AA pad on the quad so the outer-silhouette clip
// (`outer_coverage`) renders its full smoothstep band instead
// of terminating at the surface rect. Same rationale as
// fill_rect. `u_size` / `u_radii` stay anchored to `target`.
let pad = 1.0_f32;
let expanded = Rect
{
x: target.x - pad,
y: target.y - pad,
width: target.width + 2.0 * pad,
height: target.height + 2.0 * pad,
};
let mvp = ortho_rect( self.width, self.height, expanded );
// SAFETY: see module doc. We swap the global blend state for the
// duration of one draw and restore the canvas-wide default
// `(ONE, ONE_MINUS_SRC_ALPHA)` at the end of the block so the
// next draw inherits the expected pipeline blend.
unsafe
{
// Switch the blend state for this one draw.
match shadow.blend
{
BlendMode::Normal => { /* already the pipeline default */ }
BlendMode::PlusLighter => self.gl.blend_func( glow::ONE, glow::ONE ),
BlendMode::Multiply => self.gl.blend_func_separate
(
glow::DST_COLOR, glow::ZERO,
glow::DST_ALPHA, glow::ZERO,
),
BlendMode::Screen => self.gl.blend_func( glow::ONE_MINUS_DST_COLOR, glow::ONE ),
BlendMode::Overlay => unreachable!( "Overlay handled above via snapshot" ),
}
self.gl.use_program( Some( self.shadow_inset_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_inset_mvp ), false, &mvp );
self.gl.uniform_2_f32( Some( &self.u_inset_size ), target.width, target.height );
self.gl.uniform_2_f32( Some( &self.u_inset_padding ), pad, pad );
self.gl.uniform_4_f32_slice( Some( &self.u_inset_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_inset_spread ), shadow.spread );
self.gl.uniform_1_f32( Some( &self.u_inset_sigma ), sigma );
self.gl.uniform_2_f32( Some( &self.u_inset_offset ), shadow.offset[0], shadow.offset[1] );
self.gl.uniform_4_f32( Some( &self.u_inset_color ),
shadow.color.r, shadow.color.g, shadow.color.b, alpha );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
// Restore the pipeline default.
if !matches!( shadow.blend, BlendMode::Normal )
{
self.gl.blend_func( glow::ONE, glow::ONE_MINUS_SRC_ALPHA );
}
}
}
/// Stroke a rectangle outline. The stroke is centered on the (rounded)
/// boundary, matching tiny-skia's stroke_path so software and GPU paths
/// produce the same shape (e.g. a circular focus ring around an icon
/// button stays circular).
///
/// The drawing quad is expanded by `width / 2` so the outer half of the
/// stroke — which lies *outside* the original rect — has fragments to
/// cover; the SDF in the rect shader then clamps to the ring.
pub fn stroke_rect( &mut self, rect: Rect, color: Color, width: f32, corners: Corners )
{
let half = width * 0.5;
if self.rect_culled( rect, half + 1.0 ) { return; }
self.activate_target();
// Expand the *quad* outward so the outer half of the stroke has
// fragments to cover, plus 1 px extra so the 2 px AA band on the
// outer side of the stroke (half_w + 1 in the shader) has
// fragments too. `u_size` and `u_radii` keep their ORIGINAL
// values — they define the SDF, and the stroke's centerline must
// sit on the SDF zero-line (the original rect boundary). `u_pad`
// tells the fragment shader to remap `v_uv` from the larger quad
// back into original-rect space, so the SDF stays anchored to
// the original geometry. Growing `u_size`/`u_radii` instead
// would shift the zero-line outward and, in the circle case
// (radius = size/2), turn the result into a rounded square.
let pad = half + 1.0;
let expanded = Rect
{
x: rect.x - pad,
y: rect.y - pad,
width: rect.width + 2.0 * pad,
height: rect.height + 2.0 * pad,
};
let mvp = ortho_rect( self.width, self.height, expanded );
let alpha = color.a * self.global_alpha;
// SAFETY: see module doc. `u_rect_stroke = width > 0.0` triggers
// the stroke branch of `RECT_FRAG_SRC`. Same SDF-anchored-to-original
// remap as `fill_rect`; here the quad is padded by `half + 1.0` so
// the outer half of the stroke plus its 1 px AA band have fragments.
unsafe
{
self.gl.use_program( Some( self.rect_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_rect_mvp ), false, &mvp );
self.gl.uniform_4_f32( Some( &self.u_rect_color ), color.r, color.g, color.b, alpha );
self.gl.uniform_2_f32( Some( &self.u_rect_size ), rect.width, rect.height );
self.gl.uniform_4_f32_slice( Some( &self.u_rect_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_rect_stroke ), width );
self.gl.uniform_1_f32( Some( &self.u_rect_pad ), pad );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
}
}
/// Draw a line as a thin axis-aligned rect (diagonal lines fall back to
/// stamping small squares).
pub fn draw_line( &mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: Color, width: f32 )
{
let dx = x1 - x0;
let dy = y1 - y0;
let len = ( dx * dx + dy * dy ).sqrt();
if len < 0.1 { return; }
let min_x = x0.min( x1 );
let min_y = y0.min( y1 );
if dy.abs() < 0.1
{
self.fill_rect( Rect { x: min_x, y: min_y - width / 2.0, width: dx.abs(), height: width }, color, Corners::ZERO );
} else if dx.abs() < 0.1 {
self.fill_rect( Rect { x: min_x - width / 2.0, y: min_y, width, height: dy.abs() }, color, Corners::ZERO );
} else {
let steps = len.ceil() as usize;
for i in 0..steps
{
let t = i as f32 / len;
let px = x0 + dx * t;
let py = y0 + dy * t;
self.fill_rect( Rect { x: px - width / 2.0, y: py - width / 2.0, width, height: width }, color, Corners::ZERO );
}
}
}
}

107
src/gles_render/raii.rs Normal file
View File

@@ -0,0 +1,107 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! RAII guards for scoped GL bindings.
//!
//! The renderer normally restores GL state lazily: every draw method
//! calls [`super::GlesCanvas::activate_target`] at entry, which
//! re-binds the canvas FBO and reasserts viewport / scissor. So a
//! method that temporarily binds a different FBO (e.g. `aux_a` for a
//! snapshot) does not need to restore the previous binding — the next
//! draw will. That is a deliberate optimisation; introducing a RAII
//! restore at every site would double-bind the canvas FBO on every
//! frame.
//!
//! These guards exist for the *opposite* shape — operations that
//! genuinely change global state for the duration of a scope and
//! must guarantee restoration **even on early return / panic**.
//! Today that fits exactly one site: [`super::GlesCanvas::present`],
//! which binds the default framebuffer (id 0) and the blit program,
//! draws a fullscreen quad, and rebinds the canvas FBO + previous
//! program. Without RAII, an early return between the two binds
//! would leave the GL context with the default FBO and the wrong
//! program active — the next draw on this canvas would render to
//! the wrong target until `activate_target` ran (FBO is corrected
//! by `activate_target`; `use_program` is **not**).
//!
//! When in doubt: do not wrap `bind_framebuffer` / `use_program` in
//! a guard "for safety". The lazy-restore convention is part of the
//! renderer's contract and the guard pays for itself only when the
//! scope can exit through a path that bypasses `activate_target`.
use glow::HasContext;
/// Scoped framebuffer binding. Binds `target` on construction and
/// restores `previous` on Drop. Caller passes `previous` explicitly
/// because every site that needs this guard already knows what it
/// wants to restore (typically `self.fbo`) — querying
/// `GL_FRAMEBUFFER_BINDING` would force a sync round-trip we do not
/// need.
pub( super ) struct FboBinding<'a>
{
gl: &'a glow::Context,
previous: Option<glow::Framebuffer>,
}
impl<'a> FboBinding<'a>
{
/// Bind `target` immediately; remember `previous` to rebind on Drop.
///
/// # Safety
///
/// The GL context behind `gl` must be current on the calling thread
/// for the entire lifetime of the returned guard. Both `target` and
/// `previous` must be names produced by this same context (or
/// `None` for the default framebuffer).
pub( super ) unsafe fn scoped( gl: &'a glow::Context, target: Option<glow::Framebuffer>, previous: Option<glow::Framebuffer> ) -> Self
{
// SAFETY: forwarded from the fn's own `# Safety` contract.
unsafe { gl.bind_framebuffer( glow::FRAMEBUFFER, target ); }
Self { gl, previous }
}
}
impl<'a> Drop for FboBinding<'a>
{
fn drop( &mut self )
{
// SAFETY: the construction-time invariant (current context) is the
// guard's lifetime invariant. Restoring `previous` is a pure
// state-machine mutation.
unsafe { self.gl.bind_framebuffer( glow::FRAMEBUFFER, self.previous ); }
}
}
/// Scoped program binding. Same shape as [`FboBinding`] but for
/// `glUseProgram`. Caller passes the program to restore explicitly
/// (typically the program the next pipeline stage will need, or
/// `None` to leave nothing bound).
pub( super ) struct ProgramBinding<'a>
{
gl: &'a glow::Context,
previous: Option<glow::Program>,
}
impl<'a> ProgramBinding<'a>
{
/// Activate `target` immediately; remember `previous` to restore on Drop.
///
/// # Safety
///
/// Same as [`FboBinding::scoped`].
pub( super ) unsafe fn scoped( gl: &'a glow::Context, target: Option<glow::Program>, previous: Option<glow::Program> ) -> Self
{
// SAFETY: forwarded from the fn's own `# Safety` contract.
unsafe { gl.use_program( target ); }
Self { gl, previous }
}
}
impl<'a> Drop for ProgramBinding<'a>
{
fn drop( &mut self )
{
// SAFETY: see `FboBinding::drop`.
unsafe { self.gl.use_program( self.previous ); }
}
}

657
src/gles_render/setup.rs Normal file
View File

@@ -0,0 +1,657 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Construction + accessors for [`GlesCanvas`].
//!
//! The bulk of `GlesCanvas::new` is one big shader-compile +
//! uniform-lookup block: each of the shader programs gets compiled
//! from [`super::shaders`], its uniform locations are pulled out via
//! `get_uniform_location`, and both end up as `Copy` handles on the
//! struct so sub-canvases can share them for free. `sub_canvas` is
//! the same struct-literal again with the `new`-only bootstrap
//! elided (programs, VAO, default font all come from the parent).
use std::collections::HashMap;
use std::sync::{ Arc, OnceLock };
use fontdue::{ Font, FontSettings, LineMetrics, Metrics };
use glow::HasContext;
use crate::theme::{ FontRegistry, FontStyle };
use super::helpers::{ alloc_fbo_tex, bytemuck_cast_slice, compile_program, load_default_font_bytes };
use super::shaders::
{
BACKDROP_BLUR_H_FRAG_SRC, BACKDROP_COMPOSITE_FRAG_SRC,
BACKDROP_FAST_BLUR_H_FRAG_SRC, BACKDROP_FAST_COMPOSITE_FRAG_SRC,
BLIT_FRAG_SRC, BLIT_VERT_SRC,
GLYPH_FRAG_SRC,
LINEAR_GRADIENT_FRAG_SRC, RADIAL_GRADIENT_FRAG_SRC,
RECT_FRAG_SRC,
SHADOW_INSET_FRAG_SRC, SHADOW_INSET_OVERLAY_FRAG_SRC, SHADOW_OUTER_FRAG_SRC,
SUB_BLIT_FRAG_SRC,
TEX_FRAG_SRC, VERT_SRC,
};
use super::{ GlesCanvas, GlesVersion };
/// Process-wide cache of the GLES path's default font. Avoids
/// re-reading + re-parsing the small Sora face on every surface
/// bring-up. The fallback chain (Noto Sans / CJK / Devanagari / …)
/// is owned by the crate-private system-fonts module and loaded
/// lazily per codepoint, not per canvas.
static DEFAULT_FONT_GLES: OnceLock<Arc<Font>> = OnceLock::new();
fn default_font_gles() -> Arc<Font>
{
Arc::clone( DEFAULT_FONT_GLES.get_or_init( ||
{
let bytes = load_default_font_bytes();
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
.expect( "bad font" );
Arc::new( font )
} ) )
}
impl GlesCanvas
{
pub fn new( gl: Arc<glow::Context>, version: GlesVersion, width: u32, height: u32 ) -> Self
{
let font = default_font_gles();
let rect_program = compile_program( &gl, VERT_SRC, RECT_FRAG_SRC );
let tex_program = compile_program( &gl, VERT_SRC, TEX_FRAG_SRC );
let glyph_program = compile_program( &gl, VERT_SRC, GLYPH_FRAG_SRC );
let blit_program = compile_program( &gl, BLIT_VERT_SRC, BLIT_FRAG_SRC );
let sub_blit_program = compile_program( &gl, VERT_SRC, SUB_BLIT_FRAG_SRC );
let linear_gradient_program = compile_program( &gl, VERT_SRC, LINEAR_GRADIENT_FRAG_SRC );
let radial_gradient_program = compile_program( &gl, VERT_SRC, RADIAL_GRADIENT_FRAG_SRC );
let shadow_outer_program = compile_program( &gl, VERT_SRC, SHADOW_OUTER_FRAG_SRC );
let shadow_inset_program = compile_program( &gl, VERT_SRC, SHADOW_INSET_FRAG_SRC );
let shadow_inset_overlay_program = compile_program( &gl, VERT_SRC, SHADOW_INSET_OVERLAY_FRAG_SRC );
let backdrop_blur_h_program = compile_program( &gl, BLIT_VERT_SRC, BACKDROP_BLUR_H_FRAG_SRC );
let backdrop_composite_program = compile_program( &gl, VERT_SRC, BACKDROP_COMPOSITE_FRAG_SRC );
let backdrop_fast_blur_h_program = compile_program( &gl, BLIT_VERT_SRC, BACKDROP_FAST_BLUR_H_FRAG_SRC );
let backdrop_fast_composite_program = compile_program( &gl, VERT_SRC, BACKDROP_FAST_COMPOSITE_FRAG_SRC );
// SAFETY: every program in scope has just been linked successfully by
// `compile_program` (which panics on link failure), so `get_uniform_location`
// on these programs is well-defined. Each name argument is a `'static`
// ASCII literal — `glow` will not invoke UB on a malformed C string.
// `get_uniform_location` does not mutate the GL state machine, so this
// block has no interaction with whatever bindings precede it.
let (
u_rect_mvp, u_rect_color, u_rect_size, u_rect_radii, u_rect_stroke, u_rect_pad,
u_tex_mvp, u_tex_opacity, u_tex_sampler,
u_glyph_mvp, u_glyph_color, u_glyph_opacity, u_glyph_sampler,
u_blit_sampler,
u_subblit_mvp, u_subblit_sampler, u_subblit_opacity, u_subblit_fade_bottom, u_subblit_height_px,
u_lingrad_mvp, u_lingrad_lut, u_lingrad_dir, u_lingrad_size, u_lingrad_line_length,
u_lingrad_radii, u_lingrad_pad, u_lingrad_lut_domain_min, u_lingrad_lut_domain_span,
u_radgrad_mvp, u_radgrad_lut, u_radgrad_center, u_radgrad_radius_frac, u_radgrad_size,
u_radgrad_radii, u_radgrad_pad, u_radgrad_lut_domain_min, u_radgrad_lut_domain_span,
u_shadow_mvp, u_shadow_size, u_shadow_padding, u_shadow_radii, u_shadow_spread, u_shadow_sigma, u_shadow_color,
u_inset_mvp, u_inset_size, u_inset_padding, u_inset_radii, u_inset_spread, u_inset_sigma, u_inset_offset, u_inset_color,
u_inset_ov_mvp, u_inset_ov_size, u_inset_ov_padding, u_inset_ov_radii,
u_inset_ov_spread, u_inset_ov_sigma, u_inset_ov_offset, u_inset_ov_color,
u_inset_ov_snapshot, u_inset_ov_canvas_size,
u_bd_h_source, u_bd_h_texel, u_bd_h_canvas_size, u_bd_h_sigma,
u_bd_c_mvp, u_bd_c_source, u_bd_c_canvas_size, u_bd_c_texel, u_bd_c_sigma,
u_bd_c_size, u_bd_c_padding, u_bd_c_radii, u_bd_c_tint,
u_bd_fh_source, u_bd_fh_texel, u_bd_fh_canvas_size, u_bd_fh_sigma,
u_bd_fc_mvp, u_bd_fc_source, u_bd_fc_canvas_size, u_bd_fc_texel, u_bd_fc_sigma,
u_bd_fc_size, u_bd_fc_padding, u_bd_fc_radii, u_bd_fc_tint,
) = unsafe
{(
gl.get_uniform_location( rect_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( rect_program, "u_color" ).unwrap(),
gl.get_uniform_location( rect_program, "u_size" ).unwrap(),
gl.get_uniform_location( rect_program, "u_radii" ).unwrap(),
gl.get_uniform_location( rect_program, "u_stroke" ).unwrap(),
gl.get_uniform_location( rect_program, "u_pad" ).unwrap(),
gl.get_uniform_location( tex_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( tex_program, "u_opacity" ).unwrap(),
gl.get_uniform_location( tex_program, "u_sampler" ).unwrap(),
gl.get_uniform_location( glyph_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( glyph_program, "u_color" ).unwrap(),
gl.get_uniform_location( glyph_program, "u_opacity" ).unwrap(),
gl.get_uniform_location( glyph_program, "u_sampler" ).unwrap(),
gl.get_uniform_location( blit_program, "u_sampler" ).unwrap(),
gl.get_uniform_location( sub_blit_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( sub_blit_program, "u_sampler" ).unwrap(),
gl.get_uniform_location( sub_blit_program, "u_opacity" ).unwrap(),
gl.get_uniform_location( sub_blit_program, "u_fade_bottom_px" ).unwrap(),
gl.get_uniform_location( sub_blit_program, "u_height_px" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_lut" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_dir" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_size" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_line_length" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_radii" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_pad" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_lut_domain_min" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_lut_domain_span" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_lut" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_center" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_radius_frac" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_size" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_radii" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_pad" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_lut_domain_min" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_lut_domain_span" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_size" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_padding" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_radii" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_spread" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_color" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_size" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_padding" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_radii" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_spread" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_offset" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_color" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_size" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_padding" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_radii" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_spread" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_offset" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_color" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_snapshot" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_canvas_size" ).unwrap(),
gl.get_uniform_location( backdrop_blur_h_program, "u_source" ).unwrap(),
gl.get_uniform_location( backdrop_blur_h_program, "u_texel" ).unwrap(),
gl.get_uniform_location( backdrop_blur_h_program, "u_canvas_size" ).unwrap(),
gl.get_uniform_location( backdrop_blur_h_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_source" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_canvas_size" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_texel" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_size" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_padding" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_radii" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_tint" ).unwrap(),
gl.get_uniform_location( backdrop_fast_blur_h_program, "u_source" ).unwrap(),
gl.get_uniform_location( backdrop_fast_blur_h_program, "u_texel" ).unwrap(),
gl.get_uniform_location( backdrop_fast_blur_h_program, "u_canvas_size" ).unwrap(),
gl.get_uniform_location( backdrop_fast_blur_h_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_source" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_canvas_size" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_texel" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_size" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_padding" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_radii" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_tint" ).unwrap(),
)};
let quad_vertices: [f32; 12] = [
0.0, 0.0, 1.0, 0.0, 0.0, 1.0,
1.0, 0.0, 1.0, 1.0, 0.0, 1.0,
];
// SAFETY: the GL context is current (caller contract). We allocate a
// VAO + VBO, upload `quad_vertices` (24 bytes, statically known size,
// matches `STATIC_DRAW` semantics), and configure attribute 0 to read
// 2-float vertices from the VBO at offset 0 with stride 8. The vertex
// attrib pointer is bound to `vbo` because `vbo` is the current
// `ARRAY_BUFFER` binding when `vertex_attrib_pointer_f32` runs. We
// unbind the VAO at the end so we don't strand a binding the rest of
// `new` might inherit; the VBO remains attached to the VAO and is not
// touched again until `Drop`.
let ( quad_vao, quad_vbo ) = unsafe
{
let vao = gl.create_vertex_array().unwrap();
let vbo = gl.create_buffer().unwrap();
gl.bind_vertex_array( Some( vao ) );
gl.bind_buffer( glow::ARRAY_BUFFER, Some( vbo ) );
gl.buffer_data_u8_slice(
glow::ARRAY_BUFFER,
bytemuck_cast_slice( &quad_vertices ),
glow::STATIC_DRAW,
);
gl.enable_vertex_attrib_array( 0 );
gl.vertex_attrib_pointer_f32( 0, 2, glow::FLOAT, false, 8, 0 );
gl.bind_vertex_array( None );
( vao, vbo )
};
// Build the persistent FBO (shadow canvas) and bind it as the active
// draw target. From here on, every draw method writes into the FBO;
// `present` is the only call that switches to the default framebuffer.
//
// SAFETY: the GL context is current. `create_framebuffer` allocates a
// fresh FBO name. `alloc_fbo_tex` (an `unsafe fn`) requires the same
// invariant and returns a colour-renderable RGBA texture sized to the
// caller's width × height — the call is sound because both invariants
// are established. The bind + framebuffer_texture_2d pair attach the
// texture to COLOR_ATTACHMENT0; `check_framebuffer_status` is the
// canonical post-condition check and will assert before we return a
// handle to a broken FBO. The trailing GL state (`BLEND`, `blend_func`,
// `viewport`, `clear`) is the canvas-wide default state every draw
// method assumes — see the comment block beside `blend_func` for why
// `(ONE, ONE_MINUS_SRC_ALPHA)` is the correct pair for premultiplied
// shaders.
let ( fbo, fbo_tex ) = unsafe
{
let fbo = gl.create_framebuffer().expect( "create_framebuffer" );
let fbo_tex = alloc_fbo_tex( &gl, version, width, height );
gl.bind_framebuffer( glow::FRAMEBUFFER, Some( fbo ) );
gl.framebuffer_texture_2d(
glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0,
glow::TEXTURE_2D, Some( fbo_tex ), 0,
);
let status = gl.check_framebuffer_status( glow::FRAMEBUFFER );
assert_eq!( status, glow::FRAMEBUFFER_COMPLETE, "FBO incomplete: 0x{status:x}" );
gl.enable( glow::BLEND );
// Premultiplied-alpha "over" composite: `result = src + dst * (1 - src.a)`
// applied uniformly to both colour and alpha. All shaders in this
// pipeline emit premul colour (see the note above `RECT_FRAG_SRC`),
// so `(ONE, ONE_MINUS_SRC_ALPHA)` is correct for both channels.
// Premultiplied inputs are also a requirement for the plus-lighter
// and overlay blend modes.
gl.blend_func( glow::ONE, glow::ONE_MINUS_SRC_ALPHA );
gl.viewport( 0, 0, width as i32, height as i32 );
// Clear the FBO once at startup so the first frame is not garbage.
gl.clear_color( 0.0, 0.0, 0.0, 0.0 );
gl.clear( glow::COLOR_BUFFER_BIT );
( fbo, fbo_tex )
};
Self
{
gl,
version,
font,
font_registry: None,
dpi_scale: 1.0,
global_alpha: 1.0,
width,
height,
rect_program,
tex_program,
glyph_program,
blit_program,
sub_blit_program,
linear_gradient_program,
radial_gradient_program,
shadow_outer_program,
shadow_inset_program,
shadow_inset_overlay_program,
quad_vao,
_quad_vbo: quad_vbo,
u_rect_mvp,
u_rect_color,
u_rect_size,
u_rect_radii,
u_rect_stroke,
u_rect_pad,
u_tex_mvp,
u_tex_opacity,
u_tex_sampler,
u_glyph_mvp,
u_glyph_color,
u_glyph_opacity,
u_glyph_sampler,
u_blit_sampler,
u_subblit_mvp,
u_subblit_sampler,
u_subblit_opacity,
u_subblit_fade_bottom,
u_subblit_height_px,
u_lingrad_mvp,
u_lingrad_lut,
u_lingrad_dir,
u_lingrad_size,
u_lingrad_line_length,
u_lingrad_radii,
u_lingrad_pad,
u_lingrad_lut_domain_min,
u_lingrad_lut_domain_span,
u_radgrad_mvp,
u_radgrad_lut,
u_radgrad_center,
u_radgrad_radius_frac,
u_radgrad_size,
u_radgrad_radii,
u_radgrad_pad,
u_radgrad_lut_domain_min,
u_radgrad_lut_domain_span,
u_shadow_mvp,
u_shadow_size,
u_shadow_padding,
u_shadow_radii,
u_shadow_spread,
u_shadow_sigma,
u_shadow_color,
u_inset_mvp,
u_inset_size,
u_inset_padding,
u_inset_radii,
u_inset_spread,
u_inset_sigma,
u_inset_offset,
u_inset_color,
u_inset_ov_mvp,
u_inset_ov_size,
u_inset_ov_padding,
u_inset_ov_radii,
u_inset_ov_spread,
u_inset_ov_sigma,
u_inset_ov_offset,
u_inset_ov_color,
u_inset_ov_snapshot,
u_inset_ov_canvas_size,
backdrop_blur_h_program,
u_bd_h_source,
u_bd_h_texel,
u_bd_h_canvas_size,
u_bd_h_sigma,
backdrop_composite_program,
u_bd_c_mvp,
u_bd_c_source,
u_bd_c_canvas_size,
u_bd_c_texel,
u_bd_c_sigma,
u_bd_c_size,
u_bd_c_padding,
u_bd_c_radii,
u_bd_c_tint,
backdrop_fast_blur_h_program,
u_bd_fh_source,
u_bd_fh_texel,
u_bd_fh_canvas_size,
u_bd_fh_sigma,
backdrop_fast_composite_program,
u_bd_fc_mvp,
u_bd_fc_source,
u_bd_fc_canvas_size,
u_bd_fc_texel,
u_bd_fc_sigma,
u_bd_fc_size,
u_bd_fc_padding,
u_bd_fc_radii,
u_bd_fc_tint,
glyph_cache: HashMap::new(),
image_cache: HashMap::new(),
gradient_lut_cache: HashMap::new(),
clip_scissor: None,
fbo,
fbo_tex,
aux_a: None,
aux_b: None,
}
}
/// Build a sub-canvas: a separate render target sharing this canvas's GL
/// context, font, shader programs, geometry, and uniform locations, but
/// with its own FBO + color texture sized to `width × height`. Used to
/// render content into an off-screen target that can then be composited
/// back via [`Self::blit`].
///
/// The returned canvas inherits the parent's `dpi_scale` and `global_alpha`
/// (so glyphs render at the same pixel size). Its glyph cache starts empty
/// — re-rasterising on first use is the cost of not sharing GL textures
/// across canvases.
pub fn sub_canvas( &self, width: u32, height: u32 ) -> GlesCanvas
{
let gl = Arc::clone( &self.gl );
// SAFETY: `self.gl` is the same context held by `self`; if `self` was
// constructed soundly its context is current on this thread. We
// allocate a new FBO + colour texture and attach them, then assert
// completeness. We deliberately leave `gl` with the new FBO bound
// instead of restoring the parent's binding — every draw method goes
// through `activate_target`, which re-binds the canvas's own FBO
// before issuing any draw call, so the transient binding cannot be
// observed by other code on this thread.
let ( fbo, fbo_tex ) = unsafe
{
let fbo = gl.create_framebuffer().expect( "create_framebuffer" );
let fbo_tex = alloc_fbo_tex( &gl, self.version, width, height );
gl.bind_framebuffer( glow::FRAMEBUFFER, Some( fbo ) );
gl.framebuffer_texture_2d(
glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0,
glow::TEXTURE_2D, Some( fbo_tex ), 0,
);
let status = gl.check_framebuffer_status( glow::FRAMEBUFFER );
assert_eq!( status, glow::FRAMEBUFFER_COMPLETE, "sub-FBO incomplete: 0x{status:x}" );
( fbo, fbo_tex )
};
GlesCanvas
{
gl,
version: self.version,
font: Arc::clone( &self.font ),
font_registry: self.font_registry.as_ref().map( Arc::clone ),
dpi_scale: self.dpi_scale,
global_alpha: self.global_alpha,
width,
height,
rect_program: self.rect_program,
tex_program: self.tex_program,
glyph_program: self.glyph_program,
blit_program: self.blit_program,
sub_blit_program: self.sub_blit_program,
linear_gradient_program: self.linear_gradient_program,
radial_gradient_program: self.radial_gradient_program,
shadow_outer_program: self.shadow_outer_program,
shadow_inset_program: self.shadow_inset_program,
shadow_inset_overlay_program: self.shadow_inset_overlay_program,
backdrop_blur_h_program: self.backdrop_blur_h_program,
backdrop_composite_program: self.backdrop_composite_program,
backdrop_fast_blur_h_program: self.backdrop_fast_blur_h_program,
backdrop_fast_composite_program: self.backdrop_fast_composite_program,
quad_vao: self.quad_vao,
_quad_vbo: self._quad_vbo,
u_rect_mvp: self.u_rect_mvp,
u_rect_color: self.u_rect_color,
u_rect_size: self.u_rect_size,
u_rect_radii: self.u_rect_radii,
u_rect_stroke: self.u_rect_stroke,
u_rect_pad: self.u_rect_pad,
u_tex_mvp: self.u_tex_mvp,
u_tex_opacity: self.u_tex_opacity,
u_tex_sampler: self.u_tex_sampler,
u_glyph_mvp: self.u_glyph_mvp,
u_glyph_color: self.u_glyph_color,
u_glyph_opacity: self.u_glyph_opacity,
u_glyph_sampler: self.u_glyph_sampler,
u_blit_sampler: self.u_blit_sampler,
u_subblit_mvp: self.u_subblit_mvp,
u_subblit_sampler: self.u_subblit_sampler,
u_subblit_opacity: self.u_subblit_opacity,
u_subblit_fade_bottom: self.u_subblit_fade_bottom,
u_subblit_height_px: self.u_subblit_height_px,
u_lingrad_mvp: self.u_lingrad_mvp,
u_lingrad_lut: self.u_lingrad_lut,
u_lingrad_dir: self.u_lingrad_dir,
u_lingrad_size: self.u_lingrad_size,
u_lingrad_line_length: self.u_lingrad_line_length,
u_lingrad_radii: self.u_lingrad_radii,
u_lingrad_pad: self.u_lingrad_pad,
u_lingrad_lut_domain_min: self.u_lingrad_lut_domain_min,
u_lingrad_lut_domain_span: self.u_lingrad_lut_domain_span,
u_radgrad_mvp: self.u_radgrad_mvp,
u_radgrad_lut: self.u_radgrad_lut,
u_radgrad_center: self.u_radgrad_center,
u_radgrad_radius_frac: self.u_radgrad_radius_frac,
u_radgrad_size: self.u_radgrad_size,
u_radgrad_radii: self.u_radgrad_radii,
u_radgrad_pad: self.u_radgrad_pad,
u_radgrad_lut_domain_min: self.u_radgrad_lut_domain_min,
u_radgrad_lut_domain_span: self.u_radgrad_lut_domain_span,
u_shadow_mvp: self.u_shadow_mvp,
u_shadow_size: self.u_shadow_size,
u_shadow_padding: self.u_shadow_padding,
u_shadow_radii: self.u_shadow_radii,
u_shadow_spread: self.u_shadow_spread,
u_shadow_sigma: self.u_shadow_sigma,
u_shadow_color: self.u_shadow_color,
u_inset_mvp: self.u_inset_mvp,
u_inset_size: self.u_inset_size,
u_inset_padding: self.u_inset_padding,
u_inset_radii: self.u_inset_radii,
u_inset_spread: self.u_inset_spread,
u_inset_sigma: self.u_inset_sigma,
u_inset_offset: self.u_inset_offset,
u_inset_color: self.u_inset_color,
u_inset_ov_mvp: self.u_inset_ov_mvp,
u_inset_ov_size: self.u_inset_ov_size,
u_inset_ov_padding: self.u_inset_ov_padding,
u_inset_ov_radii: self.u_inset_ov_radii,
u_inset_ov_spread: self.u_inset_ov_spread,
u_inset_ov_sigma: self.u_inset_ov_sigma,
u_inset_ov_offset: self.u_inset_ov_offset,
u_inset_ov_color: self.u_inset_ov_color,
u_inset_ov_snapshot: self.u_inset_ov_snapshot,
u_inset_ov_canvas_size: self.u_inset_ov_canvas_size,
u_bd_h_source: self.u_bd_h_source,
u_bd_h_texel: self.u_bd_h_texel,
u_bd_h_canvas_size: self.u_bd_h_canvas_size,
u_bd_h_sigma: self.u_bd_h_sigma,
u_bd_c_mvp: self.u_bd_c_mvp,
u_bd_c_source: self.u_bd_c_source,
u_bd_c_canvas_size: self.u_bd_c_canvas_size,
u_bd_c_texel: self.u_bd_c_texel,
u_bd_c_sigma: self.u_bd_c_sigma,
u_bd_c_size: self.u_bd_c_size,
u_bd_c_padding: self.u_bd_c_padding,
u_bd_c_radii: self.u_bd_c_radii,
u_bd_c_tint: self.u_bd_c_tint,
u_bd_fh_source: self.u_bd_fh_source,
u_bd_fh_texel: self.u_bd_fh_texel,
u_bd_fh_canvas_size: self.u_bd_fh_canvas_size,
u_bd_fh_sigma: self.u_bd_fh_sigma,
u_bd_fc_mvp: self.u_bd_fc_mvp,
u_bd_fc_source: self.u_bd_fc_source,
u_bd_fc_canvas_size: self.u_bd_fc_canvas_size,
u_bd_fc_texel: self.u_bd_fc_texel,
u_bd_fc_sigma: self.u_bd_fc_sigma,
u_bd_fc_size: self.u_bd_fc_size,
u_bd_fc_padding: self.u_bd_fc_padding,
u_bd_fc_radii: self.u_bd_fc_radii,
u_bd_fc_tint: self.u_bd_fc_tint,
glyph_cache: HashMap::new(),
image_cache: HashMap::new(),
gradient_lut_cache: HashMap::new(),
clip_scissor: None,
fbo,
fbo_tex,
aux_a: None,
aux_b: None,
}
}
pub fn size( &self ) -> ( u32, u32 ) { ( self.width, self.height ) }
/// Discard all cached gradient LUT textures. Call after a theme change
/// so stale LUTs for old palette colours are freed and rebuilt fresh.
pub fn clear_gradient_cache( &mut self )
{
// SAFETY: GL context is current (canvas invariant). Each texture in
// `gradient_lut_cache` was created through this same context in
// `gradient.rs::ensure_lut`, so deleting them through the same context
// is well-defined. `drain` consumes the entries so the same name is
// never deleted twice.
unsafe
{
for ( _, tex ) in self.gradient_lut_cache.drain()
{
self.gl.delete_texture( tex );
}
}
}
pub fn dpi_scale( &self ) -> f32 { self.dpi_scale }
pub fn set_dpi_scale( &mut self, s: f32 ) { self.dpi_scale = s; }
pub fn global_alpha( &self ) -> f32 { self.global_alpha }
pub fn set_global_alpha( &mut self, a: f32 ) { self.global_alpha = a; }
pub fn font( &self ) -> &Font { &self.font }
/// Install a theme font registry so [`Self::font_for`] can resolve
/// family+weight+style triples declared by the theme's `fonts` block.
pub fn set_font_registry( &mut self, registry: Arc<FontRegistry> )
{
self.font_registry = Some( registry );
}
/// Resolve a specific font from the theme registry, falling back to the
/// canvas' default [`Self::font`] when no registry is installed or the
/// triple cannot be satisfied.
pub fn font_for( &self, family: &str, weight: u16, style: FontStyle ) -> Arc<Font>
{
self.font_registry
.as_ref()
.and_then( |r| r.resolve( family, weight, style ) )
.unwrap_or_else( || Arc::clone( &self.font ) )
}
/// Pick the right font for `ch`. Tries the primary [`Self::font`]
/// first; on a miss, delegates to the crate-private system-fonts
/// fallback chain (lazy load of the relevant Noto pack). Falls
/// back to the primary (which paints a `.notdef` box) when no
/// installed fallback covers the codepoint.
pub fn font_for_char( &self, ch: char ) -> Arc<Font>
{
if self.font.lookup_glyph_index( ch ) != 0
{
return Arc::clone( &self.font );
}
crate::system_fonts::lookup( ch ).unwrap_or_else( || Arc::clone( &self.font ) )
}
pub fn font_metrics( &self, ch: char, size: f32 ) -> Metrics
{
self.font_for_char( ch ).metrics( ch, size * self.dpi_scale )
}
pub fn font_line_metrics( &self, size: f32 ) -> Option<LineMetrics>
{
self.font.horizontal_line_metrics( size )
}
/// Resize the FBO and viewport. The previous color attachment is freed and
/// a fresh one of the new size is attached — frame-N pixels are dropped, so
/// the caller should expect to do a full redraw immediately after a resize.
pub fn resize( &mut self, width: u32, height: u32 )
{
if width == self.width && height == self.height { return; }
self.width = width;
self.height = height;
// SAFETY: GL context is current (canvas invariant). Sequence:
// release the old colour attachment (created via this context in
// `new`/`sub_canvas`/last `resize`), allocate a fresh one of the
// new size, swap it in for `COLOR_ATTACHMENT0` of the existing FBO,
// resize the viewport to match, and clear so the first frame after
// resize is well-defined RGBA. `self.fbo` is unchanged and remains
// valid; only its colour attachment is replaced.
unsafe
{
self.gl.delete_texture( self.fbo_tex );
self.fbo_tex = alloc_fbo_tex( &self.gl, self.version, width, height );
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.gl.framebuffer_texture_2d(
glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0,
glow::TEXTURE_2D, Some( self.fbo_tex ), 0,
);
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 );
}
// 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();
}
}

812
src/gles_render/shaders.rs Normal file
View File

@@ -0,0 +1,812 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! GLES 2/3 shader sources used by `GlesCanvas`. All fragment shaders
//! emit premultiplied-alpha colour so they compose correctly under
//! the pipeline's default `glBlendFunc(ONE, ONE_MINUS_SRC_ALPHA)`.
//!
//! Every constant is `pub(super)` so only files inside
//! `crate::gles_render` can reach them — callers always go through
//! `GlesCanvas`'s public methods, not the raw shader source.
// Vertex shader shared by both programs (just transforms a unit quad).
// GLSL ES 1.00 — works on both GLES2 and GLES3 contexts (forward compatible
// when no `#version` directive is present).
pub( super ) const VERT_SRC: &str = r#"
attribute vec2 a_pos;
varying vec2 v_uv;
uniform mat4 u_mvp;
void main()
{
v_uv = a_pos;
gl_Position = u_mvp * vec4(a_pos, 0.0, 1.0);
}
"#;
// Fragment shader for solid/rounded rects (signed-distance smoothstep at edge).
//
// `u_stroke == 0` ⇒ filled rect. The shader fills the rounded shape with
// `u_color`, antialiased over a 1-pixel band at the edge.
// `u_stroke > 0` ⇒ stroked outline of width `u_stroke`, centered on the
// rounded boundary, so outlines keep their corner radius.
//
// Distance formula is Iñigo Quílez's exact SDF for a rounded box, extended
// to per-corner radii by picking `r` per-quadrant:
// r = u_radii[ corner_index_from_sign( p - center ) ]
// q = abs(p - center) - (size/2 - r)
// d = min(max(q.x, q.y), 0) + length(max(q, 0)) - r
// `u_radii` is ordered `(tl, tr, br, bl)` clockwise from top-left and is
// uploaded as `Corners::to_uniform()`. A uniform-radii fill is the
// degenerate case where every component is equal — the per-quadrant
// branch collapses to the single-`r` formula.
//
// `u_pad` lets the caller draw the quad larger than `u_size` (used by
// `stroke_rect`, which expands the quad by `stroke/2` on each side so the
// outer half of the stroke has fragments to cover). The shader maps `v_uv`
// from the larger quad back into the original-rect space:
// p = v_uv * (size + 2*pad) - pad
// so `p` ranges `[-pad, size+pad]`. Keeping `u_size` and `u_radii` at the
// *original* values is essential — expanding them shifts the SDF zero-line
// outward and breaks the circle case (radius = size/2).
//
// Each component of `u_radii` is clamped to `min(size.x, size.y) * 0.5`
// before use. Callers frequently pass very large values (e.g.
// `theme::RADIUS = 100`) as a "please make this a pill / capsule"
// sentinel. Without the clamp, `size/2 - r` goes negative in the shorter
// dimension and the rounded-box formula degenerates — rendering an
// ellipse in the middle of the rect instead of a pill.
//
// Emits premultiplied-alpha colour. The pipeline blend is
// `(ONE, ONE_MINUS_SRC_ALPHA)`, so every shader that writes colour must
// premultiply its RGB by the final alpha. This matters for non-opaque
// fills (coverage from the SDF, translucent `u_color.a`) where straight-
// alpha output would under-saturate antialiased edges.
pub( super ) const RECT_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform vec4 u_color;
uniform vec2 u_size;
uniform vec4 u_radii;
uniform float u_stroke;
uniform float u_pad;
// Per-fragment corner radius lookup. `c` is the fragment position
// relative to the rect's centre. Inside the shader, p.y grows UPWARD:
// `ortho_rect` flips the v_uv axis so v_uv.y=0 maps to the bottom edge
// of the rect in screen space and v_uv.y=1 to the top. So with `c =
// p - size/2`:
// c.x < 0, c.y > 0 → top-left → r.x
// c.x > 0, c.y > 0 → top-right → r.y
// c.x > 0, c.y < 0 → bottom-right → r.z
// c.x < 0, c.y < 0 → bottom-left → r.w
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
float r_max = max(max(u_radii.x, u_radii.y), max(u_radii.z, u_radii.w));
if (r_max <= 0.0 && u_stroke <= 0.0 && u_pad <= 0.0)
{
float a = u_color.a;
gl_FragColor = vec4(u_color.rgb * a, a);
return;
}
vec2 p = v_uv * (u_size + 2.0 * vec2(u_pad)) - vec2(u_pad);
vec2 c = p - u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(u_size.x, u_size.y) * 0.5);
vec2 q = abs(c) - (u_size * 0.5 - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
// 2-px-wide AA band (half-width 1.0). A narrower (±0.5) band is
// only sampled when pixel centres happen to fall inside it — when
// the rasterizer grid aligns with the edge at subpixel ±0.5 the
// band is jumped over and the transition becomes a binary step
// (visible stair-stepping on curved edges). Doubling the band
// guarantees at least one partial-coverage sample per scanline
// regardless of alignment. The quad pad set by the caller is 1 px
// so this still fits inside the geometry at d ≤ 1.
float coverage;
if (u_stroke > 0.0)
{
float half_w = u_stroke * 0.5;
coverage = 1.0 - smoothstep(half_w - 1.0, half_w + 1.0, abs(d));
} else {
coverage = 1.0 - smoothstep(-1.0, 1.0, d);
}
float a = u_color.a * coverage;
gl_FragColor = vec4(u_color.rgb * a, a);
}
"##;
// Fragment shader for RGBA textured quads (images / icons).
//
// Flips uv.y because images are uploaded top-down (CPU convention: row 0 =
// top of source) but `glTexImage2D` puts the first row at the texture's
// lower-left. With the quad orientation produced by `ortho_rect`, sampling
// `v_uv` directly would draw the source upside-down on screen.
//
// Texture data arrives PREMULTIPLIED — `upload_rgba_texture` premuls the
// straight-alpha CPU buffer once at upload. GL_LINEAR sampling can then
// blend across texel boundaries without halo artefacts (a fully opaque
// black texel next to a fully transparent white texel interpolates to
// `( 0, 0, 0, 0.5 )` instead of the halo-producing `( 0.5, 0.5, 0.5, 0.5 )`
// of straight-alpha interpolation). Multiplying premul by uniform opacity
// preserves the invariant `rgb == rgb_straight * a`.
pub( super ) const TEX_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_sampler;
uniform float u_opacity;
void main()
{
gl_FragColor = texture2D(u_sampler, vec2(v_uv.x, 1.0 - v_uv.y)) * u_opacity;
}
"##;
// Fragment shader for single-channel glyph textures with color tint. Same
// Y-flip rationale as `TEX_FRAG_SRC`. The texture is `GL_LUMINANCE`, which
// replicates the uploaded byte into `.r`, `.g`, `.b` (with `.a = 1`), so the
// coverage value is read from `.r`. We deliberately avoid `GL_ALPHA` here:
// some Mesa GLES3 paths handle the legacy alpha-only format inconsistently
// (sampled `.a` returns near-zero in glyph interiors, leaving only the
// antialias edges visible — text appears as thin faded outlines instead of
// solid strokes). `LUMINANCE` is also a legacy format but its mapping to
// `.r=.g=.b=data` is well-supported across ES2/ES3 drivers.
pub( super ) const GLYPH_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_sampler;
uniform vec4 u_color;
uniform float u_opacity;
void main()
{
float coverage = texture2D(u_sampler, vec2(v_uv.x, 1.0 - v_uv.y)).r;
float a = u_color.a * coverage * u_opacity;
gl_FragColor = vec4(u_color.rgb * a, a);
}
"##;
// Vertex shader for the present-blit: maps the unit quad to fullscreen NDC
// without going through an MVP. UVs are equal to a_pos so the FBO appears
// the same way up on the default framebuffer as it was rendered into the FBO
// (both use GL pixel coords with origin at bottom-left).
pub( super ) const BLIT_VERT_SRC: &str = r#"
attribute vec2 a_pos;
varying vec2 v_uv;
void main()
{
v_uv = a_pos;
gl_Position = vec4(a_pos * 2.0 - 1.0, 0.0, 1.0);
}
"#;
// Fragment shader for the present-blit: straight texture sample, no opacity.
pub( super ) const BLIT_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_sampler;
void main()
{
gl_FragColor = texture2D(u_sampler, v_uv);
}
"#;
// Fragment shader for inter-FBO blits (used by `blit` to composite a sub-canvas
// back onto its parent). Reuses `VERT_SRC` so the quad is positioned via
// `u_mvp` like any other textured draw. No Y-flip: source and destination are
// both FBOs storing content in GL's native bottom-up convention, so sampling
// at `v_uv` puts the visually-top row of the source on the top of the dest.
//
// `u_fade_bottom_px` feathers the bottom edge of the blit: the last
// `u_fade_bottom_px` visible rows ramp the source alpha linearly from 1.0
// (at the inner edge of the band) to 0.0 (at the very bottom row), so a
// growing viewport does not look like a knife cut against whatever is
// behind it. `v_uv.y == 1.0` is the visually-top row of the source (FBO
// origin is bottom-left), so `v_uv.y * u_height_px` is the distance in
// source pixels from the bottom; the linear ramp falls out of dividing
// that by `u_fade_bottom_px` and clamping. With `u_fade_bottom_px == 0`
// the divide is skipped and the blit stays hard-edged. Linear (not
// smoothstep) because premultiplied output multiplied by a linear alpha
// already reads as a soft fade — smoothstep would compress the ramp into
// fewer effective rows and reintroduce a faint shoulder.
pub( super ) const SUB_BLIT_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_sampler;
uniform float u_opacity;
uniform float u_fade_bottom_px;
uniform float u_height_px;
void main()
{
vec4 c = texture2D(u_sampler, v_uv);
float fade = 1.0;
if (u_fade_bottom_px > 0.0)
{
float y_from_bottom = v_uv.y * u_height_px;
fade = clamp(y_from_bottom / u_fade_bottom_px, 0.0, 1.0);
}
gl_FragColor = c * (u_opacity * fade);
}
"#;
// Linear gradient shader. Samples a CPU-baked 1D LUT (`u_lut`, size
// `gradient_lut::LUT_SAMPLES × 1`, RGBA8, straight-alpha) along the
// CSS linear-gradient convention: the gradient line passes through the
// centre of the rect, `0°` points up, positive angles rotate clockwise.
// Stop positions outside `[0, 1]` are already baked into the LUT via
// linear extrapolation, so the shader only remaps `t` from the extended
// domain `[u_lut_domain_min, u_lut_domain_min + u_lut_domain_span]`
// into the texture's `[0, 1]` sampling range.
//
// The same SDF as `RECT_FRAG_SRC` is reused for the rounded-rect / pill
// silhouette; the coverage multiplies the fragment's alpha before
// premultiplying.
pub( super ) const LINEAR_GRADIENT_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_lut;
uniform vec2 u_dir;
uniform vec2 u_size;
uniform float u_line_length;
uniform vec4 u_radii;
uniform float u_pad;
uniform float u_lut_domain_min;
uniform float u_lut_domain_span;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * vec2(u_pad)) - vec2(u_pad);
vec2 c = p - u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(u_size.x, u_size.y) * 0.5);
vec2 q = abs(c) - (u_size * 0.5 - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
// 2-px AA band — see RECT_FRAG_SRC for the grid-alignment rationale.
float coverage = 1.0 - smoothstep(-1.0, 1.0, d);
// Gradient evaluated in rect-local pixel space. `p` already accounts
// for `u_pad` (set by the caller to expand the quad for AA), so the
// gradient direction stays anchored to the original rect even when
// the quad spills outside.
float dist = dot(c, u_dir);
float t = 0.5 + dist / u_line_length;
float t_lut = (t - u_lut_domain_min) / u_lut_domain_span;
vec4 grad = texture2D(u_lut, vec2(clamp(t_lut, 0.0, 1.0), 0.5));
float a = grad.a * coverage;
gl_FragColor = vec4(grad.rgb * a, a);
}
"##;
// Radial gradient shader. `u_center` is in box-relative fractions
// (`[0, 1]` on each axis), `u_radius_frac` is the radial extent in the
// same fractional space. `t = distance(v_uv, u_center) / u_radius_frac`
// so stops at `position == 1.0` fall exactly at the chosen radius.
pub( super ) const RADIAL_GRADIENT_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_lut;
uniform vec2 u_center;
uniform float u_radius_frac;
uniform vec2 u_size;
uniform vec4 u_radii;
uniform float u_pad;
uniform float u_lut_domain_min;
uniform float u_lut_domain_span;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * vec2(u_pad)) - vec2(u_pad);
vec2 c = p - u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(u_size.x, u_size.y) * 0.5);
vec2 q = abs(c) - (u_size * 0.5 - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
// 2-px AA band — see RECT_FRAG_SRC for the grid-alignment rationale.
float coverage = 1.0 - smoothstep(-1.0, 1.0, d);
// Centre/extent in box-relative fractions evaluated against `p`
// (which already accounts for `u_pad`), so the radial centre stays
// anchored to the original rect when the quad spills outside.
vec2 t_uv = p / u_size;
float t = distance(t_uv, u_center) / max(u_radius_frac, 1e-6);
float t_lut = (t - u_lut_domain_min) / u_lut_domain_span;
vec4 grad = texture2D(u_lut, vec2(clamp(t_lut, 0.0, 1.0), 0.5));
float a = grad.a * coverage;
gl_FragColor = vec4(grad.rgb * a, a);
}
"##;
// Outer drop shadow shader.
//
// Rather than baking a blurred shape into an intermediate texture via a
// separable Gaussian pass, this uses the analytical soft-shadow
// approximation `exp(-d² / (2σ²))` over the rounded-rect SDF. The maths:
//
// • `d` is the signed distance from the fragment to the (possibly
// spread-expanded) shape. `d < 0` is inside, `d > 0` is outside.
// • `intensity = 1.0` inside the shape, `exp(-d²/2σ²)` outside.
// • `σ = shadow.blur / 2` (CSS blur radius → Gaussian sigma).
//
// The result is visually indistinguishable from a true Gaussian blur for
// small to moderate σ. For very large σ the tail of `exp()` departs from
// a real Gaussian, at which point a real two-pass blur or a correction
// factor would be needed.
//
// Running the shadow as a single analytic pass means there's no need for
// a per-shadow FBO, no ping-pong, and no framebuffer readback: it is all
// one cheap draw call.
// Inner (inset) shadow shader.
//
// Two SDFs cooperate here:
//
// • `d_outer` — the signed distance to the surface itself. Positive
// outside, negative inside. We multiply the final intensity by the
// smooth-step coverage of this SDF so the inset never leaks past the
// shape's own silhouette.
//
// • `d_inner` — the signed distance to the shape shifted by
// `shadow.offset` and eroded by `shadow.spread`. This is the "hole"
// the inset falls into: `d_inner ≥ 0` means the pixel is on the
// shadow side of the offset edge (full intensity); `d_inner < 0`
// means the pixel is deeper into the unshadowed interior, where the
// intensity decays as `exp(-d_inner² / (2σ²))`.
//
// Together they reproduce the CSS `inset` semantics: the shadow sits
// inside the rounded rect, biased toward the side opposite `offset`,
// and fades toward the middle with a Gaussian falloff. Premul output
// matches the rest of the pipeline.
pub( super ) const SHADOW_INSET_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform float u_spread;
uniform float u_sigma;
uniform vec2 u_offset;
uniform vec4 u_color;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
// Outer SDF: the shape itself, untransformed. Used to clip the inset
// to the silhouette. 2-px AA band — see RECT_FRAG_SRC.
vec2 half_outer = u_size * 0.5;
float r_outer = corner_radius(c, u_radii);
r_outer = min(r_outer, min(half_outer.x, half_outer.y));
vec2 q_outer = abs(c) - (half_outer - vec2(r_outer));
float d_outer = min(max(q_outer.x, q_outer.y), 0.0) + length(max(q_outer, 0.0)) - r_outer;
float outer_coverage = 1.0 - smoothstep(-1.0, 1.0, d_outer);
// Inner SDF: the shape shifted by offset and eroded by spread. Its
// distance drives the Gaussian falloff. The inner radii match the
// outer per-corner shape, eroded by `u_spread` (clamped at zero).
vec2 p_shifted = p - u_offset;
vec2 c_shifted = p_shifted - u_size * 0.5;
vec2 half_inner = half_outer - vec2(u_spread);
// Degenerate case: spread larger than half the shape erodes it to a
// point. Guard so `half_inner` never goes negative.
half_inner = max(half_inner, vec2(1e-3));
vec4 inner_radii = max(u_radii - vec4(u_spread), vec4(0.0));
float r_inner = corner_radius(c_shifted, inner_radii);
r_inner = min(r_inner, min(half_inner.x, half_inner.y));
vec2 q_inner = abs(c_shifted) - (half_inner - vec2(r_inner));
float d_inner = min(max(q_inner.x, q_inner.y), 0.0) + length(max(q_inner, 0.0)) - r_inner;
float intensity;
if (d_inner >= 0.0)
{
intensity = 1.0;
}
else
{
float s = max(u_sigma, 0.5);
intensity = exp(-(d_inner * d_inner) / (2.0 * s * s));
}
intensity *= outer_coverage;
float a = u_color.a * intensity;
gl_FragColor = vec4(u_color.rgb * a, a);
}
"##;
pub( super ) const SHADOW_OUTER_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform float u_spread;
uniform float u_sigma;
uniform vec4 u_color;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
vec2 half_sz = u_size * 0.5 + vec2(u_spread);
// Per-corner shadow radius — outer shape grown uniformly by spread.
vec4 r_base = max(u_radii + vec4(u_spread), vec4(0.0));
float r = corner_radius(c, r_base);
r = min(r, min(half_sz.x, half_sz.y));
vec2 q = abs(c) - (half_sz - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
float intensity;
if (d <= 0.0)
{
intensity = 1.0;
}
else
{
float s = max(u_sigma, 0.5);
intensity = exp(-(d * d) / (2.0 * s * s));
}
float a = u_color.a * intensity;
gl_FragColor = vec4(u_color.rgb * a, a);
}
"##;
// Horizontal Gaussian blur for the backdrop pipeline. Part one of the
// separable-Gaussian pair: samples the aux_a snapshot horizontally and
// writes to aux_b at the fragment's pixel position. Drawn over a
// vertically-extended surface rect so the vertical pass — which reads
// aux_b up to ±3σ away from each target pixel — has valid data wherever
// it samples. `u_canvas_size` maps `gl_FragCoord` into the aux texture,
// and the aux pair is sized to the main FBO so that mapping is trivial.
//
// Kernel: 41 taps (`RADIUS = 20`), weights computed inline as
// `exp(-i²/(2σ²))` and normalized by the sum. At σ ≈ 11 this captures
// ~93 % of the Gaussian mass — the remainder is a faint tail that is
// visually imperceptible. For σ past ~15 the kernel radius would need
// to grow or a downsample pass would be required; everything else in
// the pipeline already deals in σ and would not change.
pub( super ) const BACKDROP_BLUR_H_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_source;
uniform vec2 u_texel;
uniform vec2 u_canvas_size;
uniform float u_sigma;
void main()
{
const int RADIUS = 20;
vec2 uv = gl_FragCoord.xy / u_canvas_size;
vec4 total = vec4(0.0);
float total_w = 0.0;
for (int i = -RADIUS; i <= RADIUS; i++)
{
float fi = float(i);
float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
total += texture2D(u_source, uv + fi * u_texel) * w;
total_w += w;
}
gl_FragColor = total / total_w;
}
"#;
// Vertical Gaussian blur + SDF clip + tint, the last pass of the
// backdrop pipeline. Runs on a quad matching the surface rect and
// writes to the main FBO.
//
// Per fragment: (1) computes the rounded-rect SDF coverage just like
// the rect shader so the backdrop is clipped to the surface shape with
// a 1-pixel anti-aliased edge; (2) samples `u_source` (the H-blurred
// aux_b) vertically with the same 41-tap Gaussian as the H pass; (3)
// applies the optional `u_tint` over the blurred sample using standard
// premul-over math; (4) outputs premultiplied with `alpha = coverage`.
//
// Alpha handling. The output is `(result_rgb * coverage, coverage)` —
// not `(result_rgb * coverage * result_a, coverage * result_a)`. The
// snapshot holds premultiplied content that is effectively opaque
// inside the canvas (every pixel has been written by some draw, even
// if that draw was `clear(0,0,0,0)`; the FBO is never sampled outside
// the canvas bounds). Treating the blurred sample's alpha as 1.0 at
// output time means the composite pass REPLACES the base pixel inside
// the surface shape with the tinted blurred content, rather than
// alpha-blending on top of it. That is the correct semantics for
// `backdrop-filter`: you want the original content to disappear
// entirely where the surface covers it, replaced by the blurred
// version.
//
// `fill_backdrop` runs this before outer shadows / fill / insets so
// later passes composite on top of the blurred backdrop.
pub( super ) const BACKDROP_COMPOSITE_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_source;
uniform vec2 u_canvas_size;
uniform vec2 u_texel;
uniform float u_sigma;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform vec4 u_tint;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
const int RADIUS = 20;
// Rounded-rect SDF clip. 2-px AA band — see RECT_FRAG_SRC.
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
vec2 half_sz = u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(half_sz.x, half_sz.y));
vec2 q = abs(c) - (half_sz - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
float coverage = 1.0 - smoothstep(-1.0, 1.0, d);
if (coverage <= 0.0) { discard; }
// Vertical Gaussian on the H-blurred source.
vec2 uv = gl_FragCoord.xy / u_canvas_size;
vec4 total = vec4(0.0);
float total_w = 0.0;
for (int i = -RADIUS; i <= RADIUS; i++)
{
float fi = float(i);
float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
total += texture2D(u_source, uv + fi * u_texel) * w;
total_w += w;
}
vec4 blurred = total / total_w;
// Optional tint, "tint over blurred" in premul space.
vec3 tint_premul = u_tint.rgb * u_tint.a;
vec3 result_rgb = tint_premul + blurred.rgb * (1.0 - u_tint.a);
gl_FragColor = vec4(result_rgb * coverage, coverage);
}
"#;
// ── Fast (low-quality) variants of the backdrop shaders ────────────────
//
// Same separable-Gaussian pipeline as the full-quality pair above, but
// with `RADIUS = 4` instead of `RADIUS = 20`. That cuts each pass from
// 41 taps to 9 — a ~4.5× reduction in fragment-shader work — and
// shrinks the snapshot region the renderer has to copy from the main
// FBO from `target ± 21` to `target ± 5` pixels. Used during motion
// via [`super::low_quality_paint`]; the static frame is painted with
// the full-quality pair.
//
// `u_sigma` is still a uniform so the CPU side can clamp it to a
// value compatible with the smaller kernel (typically ≤ 2.0), keeping
// the kernel a sensible Gaussian rather than a sharply truncated one.
// Visually this means a thinner blur band during motion, which fades
// back to the full blur on the static frame.
pub( super ) const BACKDROP_FAST_BLUR_H_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_source;
uniform vec2 u_texel;
uniform vec2 u_canvas_size;
uniform float u_sigma;
void main()
{
const int RADIUS = 4;
vec2 uv = gl_FragCoord.xy / u_canvas_size;
vec4 total = vec4(0.0);
float total_w = 0.0;
for (int i = -RADIUS; i <= RADIUS; i++)
{
float fi = float(i);
float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
total += texture2D(u_source, uv + fi * u_texel) * w;
total_w += w;
}
gl_FragColor = total / total_w;
}
"#;
pub( super ) const BACKDROP_FAST_COMPOSITE_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_source;
uniform vec2 u_canvas_size;
uniform vec2 u_texel;
uniform float u_sigma;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform vec4 u_tint;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
const int RADIUS = 4;
// Rounded-rect SDF clip — identical to the full-quality variant.
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
vec2 half_sz = u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(half_sz.x, half_sz.y));
vec2 q = abs(c) - (half_sz - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
float coverage = 1.0 - smoothstep(-1.0, 1.0, d);
if (coverage <= 0.0) { discard; }
vec2 uv = gl_FragCoord.xy / u_canvas_size;
vec4 total = vec4(0.0);
float total_w = 0.0;
for (int i = -RADIUS; i <= RADIUS; i++)
{
float fi = float(i);
float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
total += texture2D(u_source, uv + fi * u_texel) * w;
total_w += w;
}
vec4 blurred = total / total_w;
vec3 tint_premul = u_tint.rgb * u_tint.a;
vec3 result_rgb = tint_premul + blurred.rgb * (1.0 - u_tint.a);
gl_FragColor = vec4(result_rgb * coverage, coverage);
}
"#;
// Inset shadow with CSS `Overlay` blend. Uses the same SDF dance as
// `SHADOW_INSET_FRAG_SRC` to compute `intensity` (outer-silhouette clip
// + Gaussian falloff from the offset-shifted inner SDF), then samples
// the snapshotted FBO content under the fragment and applies the
// per-channel Overlay formula:
//
// overlay(base, src) = base < 0.5 ? 2 * base * src
// : 1 - 2 * (1 - base) * (1 - src)
//
// Output is premultiplied with `mask = u_color.a * intensity` — so the
// standard `(ONE, ONE_MINUS_SRC_ALPHA)` blend on top of the main FBO
// yields `result = overlay_rgb * mask + base * (1 - mask)`, i.e. the
// Overlay effect modulated by the shadow mask, composed onto the
// original backdrop. The snapshot texture holds premultiplied content
// matching the main FBO, so we unpremultiply before applying Overlay.
//
// `u_canvas_size` is the main FBO's pixel size. `gl_FragCoord` has its
// origin at bottom-left in GLES, which matches the FBO's native
// orientation, so `gl_FragCoord.xy / u_canvas_size` gives the texture
// coordinates of the pixel we're about to write to. No Y-flip needed.
pub( super ) const SHADOW_INSET_OVERLAY_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform float u_spread;
uniform float u_sigma;
uniform vec2 u_offset;
uniform vec4 u_color;
uniform sampler2D u_snapshot;
uniform vec2 u_canvas_size;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
// Outer silhouette clip — same as SHADOW_INSET_FRAG_SRC, 2-px AA band.
vec2 half_outer = u_size * 0.5;
float r_outer = corner_radius(c, u_radii);
r_outer = min(r_outer, min(half_outer.x, half_outer.y));
vec2 q_outer = abs(c) - (half_outer - vec2(r_outer));
float d_outer = min(max(q_outer.x, q_outer.y), 0.0) + length(max(q_outer, 0.0)) - r_outer;
float outer_coverage = 1.0 - smoothstep(-1.0, 1.0, d_outer);
// Offset-shifted inner SDF → Gaussian intensity. Per-corner inner
// radii match the outer shape eroded by `u_spread`.
vec2 p_shifted = p - u_offset;
vec2 c_shifted = p_shifted - u_size * 0.5;
vec2 half_inner = half_outer - vec2(u_spread);
half_inner = max(half_inner, vec2(1e-3));
vec4 inner_radii = max(u_radii - vec4(u_spread), vec4(0.0));
float r_inner = corner_radius(c_shifted, inner_radii);
r_inner = min(r_inner, min(half_inner.x, half_inner.y));
vec2 q_inner = abs(c_shifted) - (half_inner - vec2(r_inner));
float d_inner = min(max(q_inner.x, q_inner.y), 0.0) + length(max(q_inner, 0.0)) - r_inner;
float intensity;
if (d_inner >= 0.0)
{
intensity = 1.0;
}
else
{
float s = max(u_sigma, 0.5);
intensity = exp(-(d_inner * d_inner) / (2.0 * s * s));
}
intensity *= outer_coverage;
float mask = u_color.a * intensity;
// Sample the snapshot at the fragment's FBO position. The snapshot
// holds premultiplied content; divide by alpha before applying the
// straight-alpha Overlay formula. Guard alpha=0 to avoid NaNs.
vec2 snap_uv = gl_FragCoord.xy / u_canvas_size;
vec4 snap = texture2D(u_snapshot, snap_uv);
vec3 base = snap.a > 0.0 ? snap.rgb / snap.a : vec3(0.0);
vec3 src = u_color.rgb;
// Per-channel Overlay. `step(0.5, base)` yields 0 where base < 0.5
// and 1 otherwise; `mix` picks multiply vs screen accordingly.
vec3 multiply = 2.0 * base * src;
vec3 screen = 1.0 - 2.0 * (1.0 - base) * (1.0 - src);
vec3 overlay = mix(multiply, screen, step(0.5, base));
// Output premul: `(overlay * mask, mask)`. Combined with the premul
// over blend this replaces the base by `overlay` wherever mask == 1
// and leaves it untouched where mask == 0.
gl_FragColor = vec4(overlay * mask, mask);
}
"##;

176
src/gles_render/text.rs Normal file
View File

@@ -0,0 +1,176 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Text rendering for [`GlesCanvas`]: glyph-atlas cache + per-glyph
//! draw call. Each glyph is rasterised once via fontdue, uploaded as
//! a one-off `GL_LUMINANCE` texture, and stored on the canvas; the
//! per-frame hot path picks positions and issues one draw call per
//! glyph.
//!
//! `GL_LUMINANCE` is deliberate — `GL_ALPHA` has inconsistent
//! handling across Mesa GLES3 drivers (sampled `.a` returns near-zero
//! in glyph interiors, leaving only antialias edges visible), and
//! luminance's `.r = .g = .b = data` mapping is well-supported
//! across ES2/ES3.
use std::sync::Arc;
use fontdue::Font;
use glow::HasContext;
use crate::types::{ Color, Rect };
use super::helpers::{ ortho_rect, upload_alpha_texture };
use super::{ GlesCanvas, GlyphEntry };
const GLYPH_CACHE_SOFT_CAP: usize = 8192;
fn font_id( font: &Arc<Font> ) -> usize
{
Arc::as_ptr( font ) as usize
}
impl GlesCanvas
{
pub fn draw_text( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color )
{
self.draw_text_inner( text, x, y, size, color, None );
}
/// Draw `text` using `font` instead of the canvas default + lazy
/// system-font fallback chain. Glyphs from this font live under
/// their own atlas keys.
pub fn draw_text_with_font( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: &Arc<Font> )
{
self.draw_text_inner( text, x, y, size, color, Some( font ) );
}
fn draw_text_inner( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: Option<&Arc<Font>> )
{
self.activate_target();
let scaled = size * self.dpi_scale;
let mut cursor_x = x;
for ch in text.chars()
{
let size_key = (scaled * 10.0) as u32;
let ( id, font_arc ): ( usize, Arc<Font> ) = match font
{
Some( f ) =>
{
if f.lookup_glyph_index( ch ) != 0
{
( font_id( f ), Arc::clone( f ) )
}
else
{
self.font_id_for_char( ch )
}
}
None => self.font_id_for_char( ch ),
};
let key = ( ch, size_key, id );
if !self.glyph_cache.contains_key( &key )
{
if self.glyph_cache.len() >= GLYPH_CACHE_SOFT_CAP
{
self.evict_glyph_cache_half();
}
let ( metrics, bitmap ) = font_arc.rasterize( ch, scaled );
if metrics.width > 0 && metrics.height > 0
{
let tex = upload_alpha_texture( &self.gl, &bitmap, metrics.width as i32, metrics.height as i32 );
self.glyph_cache.insert( key, GlyphEntry
{
texture: tex,
metrics,
tex_w: metrics.width as i32,
tex_h: metrics.height as i32,
} );
} else {
cursor_x += metrics.advance_width;
continue;
}
}
if let Some( entry ) = self.glyph_cache.get( &key )
{
let gx = ( cursor_x + entry.metrics.xmin as f32 ).round();
let gy = ( y - entry.metrics.height as f32 - entry.metrics.ymin as f32 + 1.0 ).round();
let dest = Rect
{
x: gx,
y: gy,
width: entry.tex_w as f32,
height: entry.tex_h as f32,
};
self.draw_glyph_texture( entry.texture, dest, color, self.global_alpha );
cursor_x += entry.metrics.advance_width;
}
}
}
pub fn measure_text( &self, text: &str, size: f32 ) -> f32
{
text.chars().map( |ch|
{
self.font_for_char( ch ).metrics( ch, size * self.dpi_scale ).advance_width
} ).sum()
}
pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc<Font> ) -> f32
{
text.chars().map( |ch|
{
let f = if font.lookup_glyph_index( ch ) != 0
{
Arc::clone( font )
}
else
{
self.font_for_char( ch )
};
f.metrics( ch, size * self.dpi_scale ).advance_width
} ).sum()
}
fn font_id_for_char( &self, ch: char ) -> ( usize, Arc<Font> )
{
let font = self.font_for_char( ch );
let id = Arc::as_ptr( &font ) as usize;
( id, font )
}
fn evict_glyph_cache_half( &mut self )
{
let drop_n = self.glyph_cache.len() / 2;
let victims: Vec<_> = self.glyph_cache.keys().copied().take( drop_n ).collect();
for key in victims
{
if let Some( entry ) = self.glyph_cache.remove( &key )
{
unsafe { self.gl.delete_texture( entry.texture ); }
}
}
}
fn draw_glyph_texture( &self, texture: glow::Texture, dest: Rect, color: Color, opacity: f32 )
{
let mvp = ortho_rect( self.width, self.height, dest );
unsafe
{
self.gl.use_program( Some( self.glyph_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_glyph_mvp ), false, &mvp );
self.gl.uniform_4_f32( Some( &self.u_glyph_color ), color.r, color.g, color.b, color.a );
self.gl.uniform_1_f32( Some( &self.u_glyph_opacity ), opacity );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( texture ) );
self.gl.uniform_1_i32( Some( &self.u_glyph_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 );
}
}
}