First commit. Version 0.1.0
This commit is contained in:
158
src/gles_render/clip.rs
Normal file
158
src/gles_render/clip.rs
Normal 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 1–3 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(),
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
342
src/gles_render/framebuffer.rs
Normal file
342
src/gles_render/framebuffer.rs
Normal 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
293
src/gles_render/helpers.rs
Normal 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
168
src/gles_render/image.rs
Normal 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
387
src/gles_render/mod.rs
Normal 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 1–3
|
||||
//! 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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
545
src/gles_render/primitives.rs
Normal file
545
src/gles_render/primitives.rs
Normal 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
107
src/gles_render/raii.rs
Normal 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
657
src/gles_render/setup.rs
Normal 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
812
src/gles_render/shaders.rs
Normal 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
176
src/gles_render/text.rs
Normal 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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user