// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! 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>` //! 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. //! //! 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 //! canvas' 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. 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; /// 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; /// 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 ) { if !crate::render::helpers::validate_rgba_dims( "GlesCanvas", rgba_data, img_w, img_h ) { 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 ) ); self.image_cache_bytes += ( img_w as usize ) * ( img_h as usize ) * 4; self.image_cache_lru.push_back( cache_key ); self.evict_image_cache_overflow(); } else if let Some( pos ) = self.image_cache_lru.iter().position( |k| *k == cache_key ) { self.image_cache_lru.remove( pos ); self.image_cache_lru.push_back( cache_key ); } // 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. Eviction ran // before this borrow 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 ); } } } /// Delete least-recently-drawn cached textures until the estimated /// byte total fits [`IMAGE_CACHE_MAX_BYTES`]. The back of the LRU — /// the entry the current draw is about to use — is never removed. fn evict_image_cache_overflow( &mut self ) { while self.image_cache_bytes > IMAGE_CACHE_MAX_BYTES && self.image_cache_lru.len() > 1 { let Some( key ) = self.image_cache_lru.pop_front() else { break }; if let Some( ( tex, w, h ) ) = self.image_cache.remove( &key ) { self.image_cache_bytes = self.image_cache_bytes .saturating_sub( ( w as usize ) * ( h as usize ) * 4 ); // SAFETY: `tex` was created through `self.gl` in the insert // path above and just left the map, so it is deleted exactly // once. Deleting a bound texture is defined in GLES (the // binding reverts to 0); no draw is in flight here. unsafe { self.gl.delete_texture( tex ); } } } } /// 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 ); } } }