ltk/gles_render/image.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233
// 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. `draw_image_keyed` takes the caller's key
//! in place of the fingerprint, in a key space of its own.
//!
//! The cache is bounded to [`IMAGE_CACHE_MAX_BYTES`] of estimated GPU
//! memory (RGBA8: `w × h × 4` per texture) with least-recently-drawn
//! eviction, so a stream of distinct buffers (a photo carousel, video
//! thumbnails) recycles textures instead of growing GPU memory for the
//! context's lifetime. The most recent entry is never evicted, so a
//! single texture larger than the whole budget still draws — the cache
//! then holds that one entry until something replaces it.
//!
//! One cache serves every canvas and sub-canvas on the same GL context
//! (`shared_image_cache`), so a surface that is torn down and recreated
//! draws its images without uploading them again.
use glow::HasContext;
use crate::render::helpers::{ fingerprint_rgba, ImageId };
use crate::render::lru::Lru;
use crate::types::Rect;
use super::helpers::{ ortho_rect, upload_rgba_texture };
use super::GlesCanvas;
/// Byte budget for the image texture cache. 32 MiB holds a phone-sized
/// wallpaper (~10 MB at 1080×2400) plus a working set of icons and
/// thumbnails; sized for mobile GPUs where this memory competes with
/// the compositor.
pub const IMAGE_CACHE_MAX_BYTES: usize = 32 * 1024 * 1024;
type ImageKey = ( u32, u32, ImageId );
/// Content-keyed texture cache shared by every canvas on one GL context.
pub( super ) struct ImageCache
{
images: Lru<ImageKey, glow::Texture>,
/// Pre-rendered themed surfaces (shadows + fill + insets), keyed by
/// size and style; see `surface_cache.rs`.
surfaces: Lru<u64, ( glow::Texture, u32, u32 )>,
}
/// Byte budget for the pre-rendered surface textures.
pub const SURFACE_CACHE_MAX_BYTES: usize = 16 * 1024 * 1024;
/// Estimated GPU bytes of an RGBA8 texture.
fn texture_bytes( w: u32, h: u32 ) -> usize
{
( w as usize ) * ( h as usize ) * 4
}
impl ImageCache
{
fn new() -> Self
{
Self { images: Lru::new( IMAGE_CACHE_MAX_BYTES ), surfaces: Lru::new( SURFACE_CACHE_MAX_BYTES ) }
}
pub( super ) fn surface( &mut self, key: u64 ) -> Option<( glow::Texture, u32, u32 )>
{
self.surfaces.get( &key ).copied()
}
pub( super ) fn insert_surface( &mut self, gl: &glow::Context, key: u64, tex: glow::Texture, w: u32, h: u32 )
{
for ( old, _, _ ) in self.surfaces.insert( key, ( tex, w, h ), texture_bytes( w, h ) )
{
// SAFETY: `old` was created through `gl` in `end_offscreen` and
// just left the cache, so it is deleted exactly once.
unsafe { gl.delete_texture( old ); }
}
}
/// The texture for `key`, uploaded by `upload` when it is not cached yet.
fn image( &mut self, gl: &glow::Context, key: ImageKey, upload: impl FnOnce() -> glow::Texture ) -> glow::Texture
{
if let Some( &tex ) = self.images.get( &key ) { return tex; }
let tex = upload();
for old in self.images.insert( key, tex, texture_bytes( key.0, key.1 ) )
{
// SAFETY: `old` was created through `gl` in `draw_image_data`
// and just left the cache, so it is deleted exactly once.
unsafe { gl.delete_texture( old ); }
}
tex
}
}
thread_local!
{
static SHARED_IMAGE_CACHES: std::cell::RefCell<Vec<( usize, std::rc::Rc<std::cell::RefCell<ImageCache>> )>> =
const { std::cell::RefCell::new( Vec::new() ) };
}
/// The cache for the context behind `gl`, created on first use.
pub( super ) fn shared_image_cache( gl: &std::sync::Arc<glow::Context> ) -> std::rc::Rc<std::cell::RefCell<ImageCache>>
{
let key = std::sync::Arc::as_ptr( gl ) as usize;
SHARED_IMAGE_CACHES.with( |caches|
{
let mut caches = caches.borrow_mut();
if let Some( ( _, c ) ) = caches.iter().find( |( k, _ )| *k == key )
{
return std::rc::Rc::clone( c );
}
let c = std::rc::Rc::new( std::cell::RefCell::new( ImageCache::new() ) );
caches.push( ( key, std::rc::Rc::clone( &c ) ) );
c
} )
}
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 )
{
self.draw_image( ImageId::Content( fingerprint_rgba( rgba_data ) ), rgba_data, img_w, img_h, dest, opacity );
}
pub fn draw_image_keyed( &mut self, key: u64, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 )
{
self.draw_image( ImageId::Keyed( key ), rgba_data, img_w, img_h, dest, opacity );
}
fn draw_image( &mut self, id: ImageId, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 )
{
if !crate::render::helpers::validate_rgba_dims( "GlesCanvas", rgba_data, img_w, img_h )
{
return;
}
self.activate_target();
// The (w, h) prefix keeps a pathological pair of buffers with
// identical bytes but different declared sizes distinct (cannot
// happen for valid input, defence in depth).
let cache_key = ( img_w, img_h, id );
let tex = self.image_cache.borrow_mut().image( &self.gl, cache_key, ||
{
upload_rgba_texture( &self.gl, self.version, rgba_data, img_w as i32, img_h as i32 )
} );
// 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(),
};
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 the
// shared cache so it outlives the call: eviction already ran and
// never removes the most recent key, which is `cache_key`.
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 );
}
}
}