diff --git a/docs/architecture.md b/docs/architecture.md index 8795903..1e8e8ad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -314,7 +314,7 @@ The cheap things and the expensive things, in rough order: - *Lower `poll_interval()` is not free.* Crustace polls every 30 s because the clock only shows HH:MM. If your UI shows seconds, `Some(Duration::from_secs(1))` is fine; if it shows nothing time-sensitive, leave it `None`. - *Scroll viewports own a sub-canvas.* They're slightly more expensive to draw than a plain column. Use them when you need clipping or actual scrolling, not as a wrapper. A plain `viewport` without `fade_bottom` is cheaper: it clips with a scissor on the parent canvas and adds no offscreen pass, so it is the right wrapper for a panel whose visible height animates. - *Themed surfaces with shadows are pre-rendered on GLES.* A `container.surface(...)` whose slot carries outer or inset shadows (all with `Normal` blend) is drawn once per (size, corners, style) into a texture held by the context's cache and blitted afterwards, so a panel of a dozen glass cards costs a dozen textured quads per frame instead of six shader passes per card. Sliders and surfaces with `PlusLighter` / `Overlay` insets keep the direct path. -- *Image textures are cached per GL context, not per canvas.* On the GLES backend every canvas and sub-canvas of a process shares one content-keyed texture cache (32 MiB, LRU), so an overlay or subsurface that is destroyed and recreated finds its icons and wallpaper resident; destroyed overlay canvases are pooled and reused too, shaders and glyph atlas included. Keep image buffers stable (`Arc` clones) so the fingerprint hits. +- *Image textures and glyphs are cached per GL context, not per canvas.* On the GLES backend every canvas and sub-canvas of a process shares one content-keyed texture cache (32 MiB, LRU) and one glyph atlas, so an overlay or subsurface that is destroyed and recreated finds its icons, wallpaper and glyphs resident; destroyed overlay canvases are pooled and reused too, shaders included. The glyph atlas is made of 512×512 pages created on demand (8 MiB in all, a glyph too large for a page gets one of its own), so writing a new glyph into a page that a pending draw samples costs the driver at most a copy of that page. Keep image buffers stable (`Arc` clones) so the fingerprint hits. - *GPU vs software*: the GLES path is selected automatically when EGL is available, with no API-level difference for the application. The two backends are not yet pixel-identical — gradients and the shadow / backdrop pipeline degrade under software; the authoritative list of gaps is the README's *Backend Differences* section. When a redraw feels sluggish: add a one-line print at the top of `view()` and confirm it's not being called more often than expected. The single most common mistake is leaving `is_animating()` returning `true` after the animation finished. diff --git a/src/gles_render/glyph_atlas.rs b/src/gles_render/glyph_atlas.rs new file mode 100644 index 0000000..1b414f6 --- /dev/null +++ b/src/gles_render/glyph_atlas.rs @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Glyph atlas shared by every canvas and sub-canvas on one GL context, +//! so a surface that is created again finds its glyphs already rasterised. +//! +//! The atlas is a set of pages rather than one large texture: a driver that +//! shadows a texture a pending draw still samples before writing to it +//! (panfrost copies the whole buffer) then copies one page, not the atlas. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::{ Arc, Weak }; + +use glow::HasContext; + +use super::GlesVersion; + +/// Side of a regular page, in texels. +pub( super ) const PAGE_SIZE: u32 = 512; +/// Largest glyph side the atlas accepts; a glyph that does not fit a regular page gets a page of its own. +const MAX_GLYPH_SIDE: u32 = 2048; +/// Texture memory all pages may use together, one byte per texel. +const MAX_BYTES: usize = 8 * 1024 * 1024; +const PAD: u32 = 1; + +/// Cached glyph: rasterized bitmap placed in one page of the atlas. +pub( super ) struct GlyphEntry +{ + pub( super ) metrics: crate::font_face::Metrics, + pub( super ) tex_w: i32, + pub( super ) tex_h: i32, + pub( super ) page: usize, + pub( super ) atlas_x: u32, + pub( super ) atlas_y: u32, +} + +/// Cache key for the GLES glyph atlas — the GPU-side mirror of the +/// software canvas's `GlyphKey`. `glyph_id` is the per-font index +/// returned by HarfBuzz shaping; `size_bits` is `f32::to_bits` of +/// `size * dpi_scale`; `font_id` is the address of the +/// `Arc` used for rasterisation. +pub( super ) type GlyphAtlasKey = ( u16, u32, usize ); + +struct Page +{ + texture: glow::Texture, + width: u32, + height: u32, + cursor_x: u32, + cursor_y: u32, + row_height: u32, +} + +impl Page +{ + fn new( gl: &glow::Context, internal: i32, format: u32, width: u32, height: u32 ) -> Self + { + // SAFETY: the GL context behind `gl` is current on this thread (canvas + // invariant); the texture is created, specified and unbound here. + let texture = unsafe + { + let tex = gl.create_texture().unwrap(); + gl.bind_texture( glow::TEXTURE_2D, Some( tex ) ); + gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 1 ); + gl.tex_image_2d( + glow::TEXTURE_2D, 0, internal, + width as i32, height as i32, 0, + format, glow::UNSIGNED_BYTE, + glow::PixelUnpackData::Slice( None ), + ); + gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 4 ); + 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 + }; + Self { texture, width, height, cursor_x: 0, cursor_y: 0, row_height: 0 } + } + + fn bytes( &self ) -> usize + { + ( self.width as usize ) * ( self.height as usize ) + } + + /// Shelf-pack a `w × h` cell, padding included. + fn alloc( &mut self, w: u32, h: u32 ) -> Option<( u32, u32 )> + { + if w > self.width || h > self.height { return None; } + if self.cursor_x + w > self.width + { + self.cursor_x = 0; + self.cursor_y += self.row_height; + self.row_height = 0; + } + if self.cursor_y + h > self.height { return None; } + let pos = ( self.cursor_x, self.cursor_y ); + self.cursor_x += w; + if h > self.row_height { self.row_height = h; } + Some( pos ) + } + + fn rewind( &mut self ) + { + self.cursor_x = 0; + self.cursor_y = 0; + self.row_height = 0; + } +} + +pub( super ) struct GlyphAtlas +{ + /// GL_R8 / GL_RED on ES3 (GL_LUMINANCE is deprecated in ES3 core and + /// some mobile drivers — Mali, certain Adreno — return GL_INVALID_ENUM + /// for it), legacy GL_LUMINANCE on ES2 where the modern single-channel + /// formats do not exist. The glyph shader samples `.r`, which carries + /// the coverage byte with either. + internal: i32, + format: u32, + pages: Vec, + /// Regular page new glyphs are packed into; pages before it are full. + current: usize, + bytes: usize, + // (glyph_id, size_key, font_id) → GlyphEntry. `glyph_id` is the + // per-font glyph index returned by HarfBuzz shaping; the cache + // therefore persists Arabic / Devanagari / CJK shaped forms + // independently of the source codepoints. + pub( super ) cache: HashMap, +} + +impl GlyphAtlas +{ + fn new( version: GlesVersion ) -> Self + { + let ( internal, format ) = match version + { + GlesVersion::V3 => ( glow::R8 as i32, glow::RED ), + GlesVersion::V2 => ( glow::LUMINANCE as i32, glow::LUMINANCE ), + }; + Self { internal, format, pages: Vec::new(), current: 0, bytes: 0, cache: HashMap::new() } + } + + pub( super ) fn page_texture( &self, page: usize ) -> glow::Texture + { + self.pages[ page ].texture + } + + pub( super ) fn page_size( &self, page: usize ) -> ( f32, f32 ) + { + let p = &self.pages[ page ]; + ( p.width as f32, p.height as f32 ) + } + + /// Place a `w × h` glyph, returning its page and position, or `None` + /// when the atlas is full and needs a [`Self::reset`]. + pub( super ) fn alloc( &mut self, gl: &glow::Context, w: u32, h: u32 ) -> Option<( usize, u32, u32 )> + { + let ( cw, ch ) = ( w + PAD, h + PAD ); + if cw > MAX_GLYPH_SIDE || ch > MAX_GLYPH_SIDE { return None; } + if cw > PAGE_SIZE || ch > PAGE_SIZE + { + if self.bytes + ( cw as usize ) * ( ch as usize ) > MAX_BYTES { return None; } + let mut page = Page::new( gl, self.internal, self.format, cw, ch ); + page.alloc( cw, ch ); + self.bytes += page.bytes(); + self.pages.push( page ); + return Some( ( self.pages.len() - 1, 0, 0 ) ); + } + loop + { + if self.current == self.pages.len() + { + let page_bytes = ( PAGE_SIZE as usize ) * ( PAGE_SIZE as usize ); + if self.bytes + page_bytes > MAX_BYTES { return None; } + self.pages.push( Page::new( gl, self.internal, self.format, PAGE_SIZE, PAGE_SIZE ) ); + self.bytes += page_bytes; + } + if let Some( ( x, y ) ) = self.pages[ self.current ].alloc( cw, ch ) + { + return Some( ( self.current, x, y ) ); + } + self.current += 1; + } + } + + /// Copy a rasterised glyph into its place in `page`. + #[ allow( clippy::too_many_arguments ) ] + pub( super ) fn upload( &self, gl: &glow::Context, page: usize, x: u32, y: u32, w: u32, h: u32, bitmap: &[u8] ) + { + // SAFETY: the context is current (canvas invariant) and the page + // texture was created through it in `Page::new`. + unsafe + { + gl.bind_texture( glow::TEXTURE_2D, Some( self.pages[ page ].texture ) ); + gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 1 ); + gl.tex_sub_image_2d( + glow::TEXTURE_2D, 0, + x as i32, y as i32, + w as i32, h as i32, + self.format, glow::UNSIGNED_BYTE, + glow::PixelUnpackData::Slice( Some( bitmap ) ), + ); + gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 4 ); + } + } + + /// Forget every glyph. Regular pages are kept for reuse; pages made + /// for a single oversized glyph are freed. + pub( super ) fn reset( &mut self, gl: &glow::Context ) + { + self.cache.clear(); + let mut bytes = 0; + self.pages.retain_mut( |p| + { + if p.width == PAGE_SIZE && p.height == PAGE_SIZE + { + p.rewind(); + bytes += p.bytes(); + return true; + } + // SAFETY: the texture was created through this context and is + // leaving the atlas, so it is deleted exactly once. + unsafe { gl.delete_texture( p.texture ); } + false + } ); + self.bytes = bytes; + self.current = 0; + } +} + +thread_local! +{ + static SHARED_ATLASES: RefCell, Rc> )>> = + const { RefCell::new( Vec::new() ) }; +} + +/// The atlas for the context behind `gl`, created on first use. +pub( super ) fn shared_glyph_atlas( gl: &Arc, version: GlesVersion ) -> Rc> +{ + SHARED_ATLASES.with( |atlases| + { + let mut atlases = atlases.borrow_mut(); + atlases.retain( |( ctx, _ )| ctx.strong_count() > 0 ); + if let Some( ( _, atlas ) ) = atlases.iter().find( |( ctx, _ )| std::ptr::eq( ctx.as_ptr(), Arc::as_ptr( gl ) ) ) + { + return Rc::clone( atlas ); + } + let atlas = Rc::new( RefCell::new( GlyphAtlas::new( version ) ) ); + atlases.push( ( Arc::downgrade( gl ), Rc::clone( &atlas ) ) ); + atlas + } ) +} diff --git a/src/gles_render/mod.rs b/src/gles_render/mod.rs index 7f26891..60b6443 100644 --- a/src/gles_render/mod.rs +++ b/src/gles_render/mod.rs @@ -32,6 +32,7 @@ //! fill_radial_gradient_rect, fill_shadow_outer, fill_shadow_inset, //! stroke_rect, draw_line}`. //! * `text` — `GlesCanvas::{draw_text, measure_text}`. +//! * `glyph_atlas` — the paged glyph atlas shared per GL context. //! * `image` — `GlesCanvas::draw_image_data`. //! * `shaders` — GLSL ES 1.00 shader sources (const strings). //! * `helpers` — free functions: `ortho_rect`, `compile_program`, @@ -61,6 +62,7 @@ pub( crate ) mod framebuffer; pub( crate ) mod clip; pub( crate ) mod primitives; pub( crate ) mod text; +pub( crate ) mod glyph_atlas; pub( crate ) mod image; pub( crate ) mod surface_cache; mod raii; @@ -104,25 +106,6 @@ pub struct BorrowedGlesTexture pub y_inverted: bool, } -/// Cached glyph: rasterized bitmap placed in the shared atlas. -pub ( super ) struct GlyphEntry -{ - pub ( super ) metrics: crate::font_face::Metrics, - pub ( super ) tex_w: i32, - pub ( super ) tex_h: i32, - pub ( super ) atlas_x: u32, - pub ( super ) atlas_y: u32, -} - -/// Cache key for the GLES glyph atlas — the GPU-side mirror of the -/// software canvas's `GlyphKey`. `glyph_id` is the per-font index -/// returned by HarfBuzz shaping; `size_bits` is `f32::to_bits` of -/// `size * dpi_scale`; `font_id` is the address of the -/// `Arc` used for rasterisation. -pub( super ) type GlyphAtlasKey = ( u16, u32, usize ); - -pub( super ) const ATLAS_SIZE: u32 = 2048; - // ─── GlesCanvas ────────────────────────────────────────────────────────────── /// GPU-accelerated canvas using EGL + GLES2/3. @@ -329,23 +312,9 @@ pub struct GlesCanvas u_bd_fc_radii: glow::UniformLocation, u_bd_fc_tint: glow::UniformLocation, - atlas_texture: glow::Texture, - /// Upload format of `atlas_texture`. On GLES3 we use the modern - /// single-channel `GL_RED` over `GL_R8`; on GLES2 we keep the - /// legacy `GL_LUMINANCE` because `GL_R8` / `GL_RED` did not exist - /// before ES3. The fragment shader samples `.r` and is identical - /// for both — `GL_LUMINANCE` replicates the single channel into - /// `.r=.g=.b`, so `.r` carries the coverage either way. - pub ( super ) atlas_format: u32, - atlas_cursor_x: u32, - atlas_cursor_y: u32, - atlas_row_height: u32, - - // (glyph_id, size_key, font_id) → GlyphEntry. `glyph_id` is the - // per-font glyph index returned by HarfBuzz shaping; the cache - // therefore persists Arabic / Devanagari / CJK shaped forms - // independently of the source codepoints. - glyph_cache: HashMap, + // Shared by every canvas and sub-canvas on the same GL context, like + // `image_cache`. + glyph_atlas: std::rc::Rc>, // Reusable texture cache for images. Keyed by // `(width, height, content fingerprint)` rather than the source @@ -410,10 +379,10 @@ pub struct GlesCanvas // ─── 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. +/// FBOs if allocated, and its gradient textures. Shader programs, the +/// quad VAO/VBO and the glyph and image caches are shared with other +/// 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 ) @@ -421,8 +390,7 @@ impl Drop for GlesCanvas // 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` + // 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 @@ -451,8 +419,6 @@ impl Drop for GlesCanvas self.gl.delete_framebuffer( fbo ); self.gl.delete_texture( tex ); } - self.glyph_cache.clear(); - self.gl.delete_texture( self.atlas_texture ); for ( _, tex ) in self.gradient_lut_cache.drain() { self.gl.delete_texture( tex ); diff --git a/src/gles_render/setup.rs b/src/gles_render/setup.rs index 160f44b..182f27b 100644 --- a/src/gles_render/setup.rs +++ b/src/gles_render/setup.rs @@ -294,39 +294,7 @@ impl GlesCanvas ( fbo, fbo_tex ) }; - // Atlas format picked per ES profile: GL_R8 / GL_RED on ES3 - // (GL_LUMINANCE is deprecated in ES3 core and some mobile drivers - // — Mali, certain Adreno — return GL_INVALID_ENUM for it), legacy - // GL_LUMINANCE on ES2 where the modern single-channel formats do - // not exist. The glyph fragment shader samples `.r` and works for - // both: GL_RED puts the byte in .r directly, GL_LUMINANCE - // replicates it into .r=.g=.b, so reading `.r` gives the same - // coverage value either way. - let ( atlas_internal, atlas_format ) = match version - { - GlesVersion::V3 => ( glow::R8 as i32, glow::RED ), - GlesVersion::V2 => ( glow::LUMINANCE as i32, glow::LUMINANCE ), - }; - let atlas_texture = unsafe - { - let tex = gl.create_texture().unwrap(); - gl.bind_texture( glow::TEXTURE_2D, Some( tex ) ); - gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 1 ); - gl.tex_image_2d( - glow::TEXTURE_2D, 0, atlas_internal, - super::ATLAS_SIZE as i32, super::ATLAS_SIZE as i32, 0, - atlas_format, glow::UNSIGNED_BYTE, - glow::PixelUnpackData::Slice( None ), - ); - gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 4 ); - 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 - }; - + let glyph_atlas = super::glyph_atlas::shared_glyph_atlas( &gl, version ); let image_cache = super::image::shared_image_cache( &gl ); Self { @@ -454,12 +422,7 @@ impl GlesCanvas u_bd_fc_padding, u_bd_fc_radii, u_bd_fc_tint, - atlas_texture, - atlas_format, - atlas_cursor_x: 0, - atlas_cursor_y: 0, - atlas_row_height: 0, - glyph_cache: HashMap::new(), + glyph_atlas, image_cache, gradient_lut_cache: HashMap::new(), clip_scissor: None, @@ -487,9 +450,8 @@ impl GlesCanvas /// 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. + /// (so glyphs render at the same pixel size), and shares the parent's glyph + /// atlas and image cache. pub fn sub_canvas( &self, width: u32, height: u32 ) -> GlesCanvas { let gl = Arc::clone( &self.gl ); @@ -515,32 +477,6 @@ impl GlesCanvas ( fbo, fbo_tex ) }; - let atlas_format = self.atlas_format; - let atlas_internal = match self.version - { - GlesVersion::V3 => glow::R8 as i32, - GlesVersion::V2 => glow::LUMINANCE as i32, - }; - let atlas_texture = unsafe - { - let tex = gl.create_texture().unwrap(); - gl.bind_texture( glow::TEXTURE_2D, Some( tex ) ); - gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 1 ); - gl.tex_image_2d( - glow::TEXTURE_2D, 0, atlas_internal, - super::ATLAS_SIZE as i32, super::ATLAS_SIZE as i32, 0, - atlas_format, glow::UNSIGNED_BYTE, - glow::PixelUnpackData::Slice( None ), - ); - gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 4 ); - 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 - }; - GlesCanvas { gl, @@ -668,12 +604,7 @@ impl GlesCanvas 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, - atlas_texture, - atlas_format, - atlas_cursor_x: 0, - atlas_cursor_y: 0, - atlas_row_height: 0, - glyph_cache: HashMap::new(), + glyph_atlas: std::rc::Rc::clone( &self.glyph_atlas ), image_cache: std::rc::Rc::clone( &self.image_cache ), gradient_lut_cache: HashMap::new(), clip_scissor: None, diff --git a/src/gles_render/shaders.rs b/src/gles_render/shaders.rs index 13b253f..a11c9d2 100644 --- a/src/gles_render/shaders.rs +++ b/src/gles_render/shaders.rs @@ -183,7 +183,7 @@ void main() // top-row-first while GL tex coords have V=0 at the bottom. // // The atlas is GL_R8 on ES3 and GL_LUMINANCE on ES2 (see -// `gles_render/text.rs` module doc and `GlesCanvas::atlas_format`). +// `gles_render/text.rs` module doc and `GlyphAtlas::format`). // Both formats put the coverage byte in `.r` when sampled, so this // shader is identical for both. pub( super ) const GLYPH_FRAG_SRC: &str = r##" diff --git a/src/gles_render/text.rs b/src/gles_render/text.rs index 06648d4..4e333ff 100644 --- a/src/gles_render/text.rs +++ b/src/gles_render/text.rs @@ -1,8 +1,9 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -//! Text rendering for [`GlesCanvas`]: shelf-packed glyph atlas + -//! per-glyph draw call. The line is shaped through +//! Text rendering for [`GlesCanvas`]: glyphs shelf-packed into the +//! context's shared, paged atlas ([`super::glyph_atlas`]) and one draw +//! call per page a line touches. The line is shaped through //! [`crate::text_shaping::shape_line`] (Unicode BiDi + per-sub-run //! rustybuzz / HarfBuzz) and each shaped glyph is rasterised by //! glyph index via [`FontFace::rasterize_indexed`], so the cache @@ -11,7 +12,7 @@ //! cached correctly without colliding with the source codepoints. //! //! The atlas format is picked per ES profile -//! ([`GlesCanvas::atlas_format`]): `GL_R8` / `GL_RED` on ES3, +//! (`GlyphAtlas::format`): `GL_R8` / `GL_RED` on ES3, //! `GL_LUMINANCE` on ES2. The fragment shader samples `.r` and is //! identical for both — `GL_RED` puts the coverage byte in `.r` //! directly, `GL_LUMINANCE` replicates it into `.r=.g=.b`. @@ -23,7 +24,8 @@ use glow::HasContext; use crate::types::{ Color, Rect }; -use super::{ ATLAS_SIZE, GlesCanvas, GlyphEntry }; +use super::GlesCanvas; +use super::glyph_atlas::GlyphEntry; const GLYPH_CACHE_SOFT_CAP: usize = 8192; @@ -58,16 +60,12 @@ impl GlesCanvas let scaled = size * self.dpi_scale; let size_key = ( scaled * 10.0 ) as u32; - unsafe - { - self.gl.active_texture( glow::TEXTURE0 ); - self.gl.bind_texture( glow::TEXTURE_2D, Some( self.atlas_texture ) ); - } + unsafe { self.gl.active_texture( glow::TEXTURE0 ); } // Resolve every glyph + its font up front so we can decide // whether the atlas needs a single reset before we start // emitting quads. Doing the reset mid-draw would orphan the - // `(atlas_x, atlas_y)` recorded in `glyph_cache` for glyphs + // `(page, atlas_x, atlas_y)` recorded in the atlas cache for glyphs // already pushed earlier in this same call. let canvas_handle = self.font_handle(); let prefer_handle = font.cloned().map( |f| @@ -132,6 +130,8 @@ impl GlesCanvas // already inserted in this same pass) at most once: a single // string longer than the atlas capacity falls back to skipping // the tail rather than oscillating. + let atlas_rc = std::rc::Rc::clone( &self.glyph_atlas ); + let mut atlas = atlas_rc.borrow_mut(); let mut reset_used = false; let mut i = 0; while i < shaped.len() @@ -139,7 +139,7 @@ impl GlesCanvas let g = &shaped[ i ]; let glyph_id = g.glyph_id as u16; let key = ( glyph_id, size_key, g.font_id ); - if self.glyph_cache.contains_key( &key ) + if atlas.cache.contains_key( &key ) { i += 1; continue; @@ -152,11 +152,12 @@ impl GlesCanvas let ( metrics, bitmap ) = font_arc.rasterize_indexed( glyph_id, scaled ); if metrics.width == 0 || metrics.height == 0 { - self.glyph_cache.insert( key, GlyphEntry + atlas.cache.insert( key, GlyphEntry { metrics, tex_w: 0, tex_h: 0, + page: 0, atlas_x: 0, atlas_y: 0, } ); @@ -165,30 +166,31 @@ impl GlesCanvas } let ( w, h ) = ( metrics.width as u32, metrics.height as u32 ); - if self.glyph_cache.len() >= GLYPH_CACHE_SOFT_CAP && !reset_used + if atlas.cache.len() >= GLYPH_CACHE_SOFT_CAP && !reset_used { - self.atlas_reset(); + atlas.reset( &self.gl ); reset_used = true; i = 0; continue; } - let pos = match self.atlas_alloc( w, h ) + let ( page, atlas_x, atlas_y ) = match atlas.alloc( &self.gl, w, h ) { Some( p ) => p, None if !reset_used => { - self.atlas_reset(); + atlas.reset( &self.gl ); reset_used = true; i = 0; continue; } None => { - self.glyph_cache.insert( key, GlyphEntry + atlas.cache.insert( key, GlyphEntry { metrics, tex_w: 0, tex_h: 0, + page: 0, atlas_x: 0, atlas_y: 0, } ); @@ -196,36 +198,24 @@ impl GlesCanvas continue; } }; - unsafe - { - self.gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 1 ); - self.gl.tex_sub_image_2d( - glow::TEXTURE_2D, 0, - pos.0 as i32, pos.1 as i32, - w as i32, h as i32, - self.atlas_format, glow::UNSIGNED_BYTE, - glow::PixelUnpackData::Slice( Some( &bitmap ) ), - ); - self.gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 4 ); - } - self.glyph_cache.insert( key, GlyphEntry + atlas.upload( &self.gl, page, atlas_x, atlas_y, w, h, &bitmap ); + atlas.cache.insert( key, GlyphEntry { metrics, tex_w: w as i32, tex_h: h as i32, - atlas_x: pos.0, - atlas_y: pos.1, + page, + atlas_x, + atlas_y, } ); i += 1; } - // Phase 2 — collect every quad's vertices in one buffer and - // flush them with a single draw call. Saves N-1 - // program/uniform/draw round-trips per text run. - let atlas_size = ATLAS_SIZE as f32; + // Phase 2 — collect the quads' vertices per atlas page and flush + // each page with a single draw call. let surface_w = self.width as f32; let surface_h = self.height as f32; - let mut verts: Vec = Vec::with_capacity( shaped.len() * 24 ); + let mut batches: Vec<( usize, Vec )> = Vec::new(); let mut color_glyphs: Vec<( Arc, Rect )> = Vec::new(); let mut cx = x; for g in shaped.iter() @@ -246,7 +236,7 @@ impl GlesCanvas cx += g.x_advance; continue; } - let Some( entry ) = self.glyph_cache.get( &key ) else + let Some( entry ) = atlas.cache.get( &key ) else { cx += g.x_advance; continue; @@ -268,11 +258,21 @@ impl GlesCanvas let y0_ndc = 1.0 - gy * 2.0 / surface_h; let y1_ndc = 1.0 - ( gy + gh ) * 2.0 / surface_h; - let u0 = entry.atlas_x as f32 / atlas_size; - let v0 = entry.atlas_y as f32 / atlas_size; - let u1 = ( entry.atlas_x as f32 + gw ) / atlas_size; - let v1 = ( entry.atlas_y as f32 + gh ) / atlas_size; + let ( page_w, page_h ) = atlas.page_size( entry.page ); + let u0 = entry.atlas_x as f32 / page_w; + let v0 = entry.atlas_y as f32 / page_h; + let u1 = ( entry.atlas_x as f32 + gw ) / page_w; + let v1 = ( entry.atlas_y as f32 + gh ) / page_h; + let verts = match batches.iter().position( |( p, _ )| *p == entry.page ) + { + Some( b ) => &mut batches[ b ].1, + None => + { + batches.push( ( entry.page, Vec::with_capacity( shaped.len() * 24 ) ) ); + &mut batches.last_mut().unwrap().1 + } + }; verts.extend_from_slice( &[ x0_ndc, y0_ndc, u0, v0, x1_ndc, y0_ndc, u1, v0, @@ -285,7 +285,7 @@ impl GlesCanvas cx += g.x_advance; } - if !verts.is_empty() + if !batches.is_empty() { unsafe { @@ -295,12 +295,17 @@ impl GlesCanvas self.gl.uniform_1_i32( Some( &self.u_glyph_batch_sampler ), 0 ); self.gl.bind_vertex_array( Some( self.glyph_batch_vao ) ); self.gl.bind_buffer( glow::ARRAY_BUFFER, Some( self.glyph_batch_vbo ) ); - self.gl.buffer_data_u8_slice( glow::ARRAY_BUFFER, super::helpers::bytemuck_cast_slice( &verts ), glow::STREAM_DRAW ); - let vertex_count = ( verts.len() / 4 ) as i32; - self.gl.draw_arrays( glow::TRIANGLES, 0, vertex_count ); + for ( page, verts ) in &batches + { + self.gl.bind_texture( glow::TEXTURE_2D, Some( atlas.page_texture( *page ) ) ); + self.gl.buffer_data_u8_slice( glow::ARRAY_BUFFER, super::helpers::bytemuck_cast_slice( verts ), glow::STREAM_DRAW ); + let vertex_count = ( verts.len() / 4 ) as i32; + self.gl.draw_arrays( glow::TRIANGLES, 0, vertex_count ); + } self.gl.bind_vertex_array( None ); } } + drop( atlas ); unsafe { self.gl.bind_texture( glow::TEXTURE_2D, None ); } @@ -374,30 +379,4 @@ impl GlesCanvas } } - fn atlas_alloc( &mut self, w: u32, h: u32 ) -> Option<( u32, u32 )> - { - const PAD: u32 = 1; - if w + PAD > ATLAS_SIZE || h + PAD > ATLAS_SIZE { return None; } - if self.atlas_cursor_x + w + PAD > ATLAS_SIZE - { - self.atlas_cursor_x = 0; - self.atlas_cursor_y += self.atlas_row_height; - self.atlas_row_height = 0; - } - if self.atlas_cursor_y + h + PAD > ATLAS_SIZE { return None; } - let x = self.atlas_cursor_x; - let y = self.atlas_cursor_y; - self.atlas_cursor_x += w + PAD; - if h + PAD > self.atlas_row_height { self.atlas_row_height = h + PAD; } - Some( ( x, y ) ) - } - - fn atlas_reset( &mut self ) - { - self.glyph_cache.clear(); - self.atlas_cursor_x = 0; - self.atlas_cursor_y = 0; - self.atlas_row_height = 0; - } - }