The GLES backend keeps one glyph atlas per GL context, split into pages, so a surface that is created again does not rasterise and upload its text again
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

Every GLES canvas and sub-canvas had a glyph atlas of its own, a 2048×2048 texture created empty with the canvas, so every surface that was torn down and built again, and every scroll viewport inside it, rasterised and uploaded all of its glyphs on its first frame. crustace's app launcher pays this on every opening: the sliding subsurface and its scroll sub-canvas are destroyed when the launcher closes, and the interactive overlay that replaces the subsurface at the top has scroll sub-canvases of its own. With `LTK_PERF_RASTER=1` on a PinePhone Pro (RK3399, Mali-T860, panfrost) the first raster of the subsurface took 175 to 330 ms of CPU and the first full raster of the overlay 175 to 260 ms, on every opening, with the GPU done 3 ms later; the user saw a delay of two or three tenths of a second before the launcher followed the finger and a stall of about one more when it reached the top. On a Librem 5 (i.MX8MQ, GC7000L, etnaviv) the same rasters took 18 to 40 ms.
Panfrost makes each glyph upload expensive. The glyphs of a line are written into the atlas with `glTexSubImage2D` after earlier lines were drawn from it, and when a pending batch still samples a texture that is written to, Mesa 25.0 allocates a new buffer for the texture and copies the whole old one into it with the CPU (`panfrost_ptr_map` in `pan_resource.c`), 4 MiB for the old atlas, read from write-combined memory. Etnaviv flushes and waits for the GPU instead (`etna_transfer_map`), whose cost does not depend on the size of the texture.
The new `gles_render/glyph_atlas.rs` holds one `GlyphAtlas` per GL context, found through `shared_glyph_atlas` as the image cache is through `shared_image_cache`, and every canvas and sub-canvas keeps an `Rc` to it, so glyphs rasterised by one surface are resident for the next. The context is held as a `Weak`, so the atlas of a context that was dropped is forgotten instead of being found again by a new context allocated at the same address. The atlas is made of 512×512 pages, created as glyphs need them, up to 8 MiB in all; a glyph larger than a page gets a page of its own, up to the 2048 px of the old atlas, and those pages are freed when the atlas is reset, the regular ones kept and rewound. A write into a page that a pending draw samples then costs panfrost a copy of 256 KiB, not of 4 MiB. The glyph cache moves into the atlas with `GlyphEntry`, which records the page of each glyph, and its key; the cap of 8192 glyphs before a reset is now for the context as a whole, not for each canvas. `draw_text` uploads through the atlas and groups the quads of a line by page, one draw call per page instead of one per line, which only differs when the glyphs of a line are split across pages. `GlesCanvas::new` and `sub_canvas` no longer create an atlas texture each, and dropping a canvas no longer deletes one. `docs/architecture.md` says that glyphs are cached per GL context too and how the atlas is paged.
Measured with `LTK_PERF_RASTER=1`, opening and closing the launcher a dozen times. On the PinePhone Pro the subsurface raster of the first opening went from 330 to 95 ms and of the later ones from 175–215 ms to 2–10 ms, with peaks of 17 and 35 ms, and the first raster of the overlay from 175–260 ms to under 1.5 ms; the other frames stay at 2 to 13 ms. On the Librem 5 the subsurface raster went from 22.7 ms on average, 85.6 ms on the first opening and over 15 ms in 9 of 14 openings, to 13.2 ms, 69.7 ms and 3 of 14, and the other frames from 7.7 ms on average and 43 ms at most to 6.8 ms and 28.5 ms.
Not done. The first raster of a session still uploads its glyphs interleaved with the draws that sample them; uploading all of them before the first draw would need a second pass over the view on every frame. There are no tests for the atlas. The image cache still identifies its context by address only.
This commit is contained in:
2026-10-15 22:34:03 +02:00
parent 3edd822918
commit 85aa85a357
6 changed files with 323 additions and 192 deletions
+1 -1
View File
@@ -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`. - *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. - *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. - *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. - *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. 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.
+255
View File
@@ -0,0 +1,255 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<FontFace>` 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<Page>,
/// 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<GlyphAtlasKey, GlyphEntry>,
}
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<Vec<( Weak<glow::Context>, Rc<RefCell<GlyphAtlas>> )>> =
const { RefCell::new( Vec::new() ) };
}
/// The atlas for the context behind `gl`, created on first use.
pub( super ) fn shared_glyph_atlas( gl: &Arc<glow::Context>, version: GlesVersion ) -> Rc<RefCell<GlyphAtlas>>
{
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
} )
}
+10 -44
View File
@@ -32,6 +32,7 @@
//! fill_radial_gradient_rect, fill_shadow_outer, fill_shadow_inset, //! fill_radial_gradient_rect, fill_shadow_outer, fill_shadow_inset,
//! stroke_rect, draw_line}`. //! stroke_rect, draw_line}`.
//! * `text` — `GlesCanvas::{draw_text, measure_text}`. //! * `text` — `GlesCanvas::{draw_text, measure_text}`.
//! * `glyph_atlas` — the paged glyph atlas shared per GL context.
//! * `image` — `GlesCanvas::draw_image_data`. //! * `image` — `GlesCanvas::draw_image_data`.
//! * `shaders` — GLSL ES 1.00 shader sources (const strings). //! * `shaders` — GLSL ES 1.00 shader sources (const strings).
//! * `helpers` — free functions: `ortho_rect`, `compile_program`, //! * `helpers` — free functions: `ortho_rect`, `compile_program`,
@@ -61,6 +62,7 @@ pub( crate ) mod framebuffer;
pub( crate ) mod clip; pub( crate ) mod clip;
pub( crate ) mod primitives; pub( crate ) mod primitives;
pub( crate ) mod text; pub( crate ) mod text;
pub( crate ) mod glyph_atlas;
pub( crate ) mod image; pub( crate ) mod image;
pub( crate ) mod surface_cache; pub( crate ) mod surface_cache;
mod raii; mod raii;
@@ -104,25 +106,6 @@ pub struct BorrowedGlesTexture
pub y_inverted: bool, 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<FontFace>` used for rasterisation.
pub( super ) type GlyphAtlasKey = ( u16, u32, usize );
pub( super ) const ATLAS_SIZE: u32 = 2048;
// ─── GlesCanvas ────────────────────────────────────────────────────────────── // ─── GlesCanvas ──────────────────────────────────────────────────────────────
/// GPU-accelerated canvas using EGL + GLES2/3. /// GPU-accelerated canvas using EGL + GLES2/3.
@@ -329,23 +312,9 @@ pub struct GlesCanvas
u_bd_fc_radii: glow::UniformLocation, u_bd_fc_radii: glow::UniformLocation,
u_bd_fc_tint: glow::UniformLocation, u_bd_fc_tint: glow::UniformLocation,
atlas_texture: glow::Texture, // Shared by every canvas and sub-canvas on the same GL context, like
/// Upload format of `atlas_texture`. On GLES3 we use the modern // `image_cache`.
/// single-channel `GL_RED` over `GL_R8`; on GLES2 we keep the glyph_atlas: std::rc::Rc<std::cell::RefCell<glyph_atlas::GlyphAtlas>>,
/// 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<GlyphAtlasKey, GlyphEntry>,
// Reusable texture cache for images. Keyed by // Reusable texture cache for images. Keyed by
// `(width, height, content fingerprint)` rather than the source // `(width, height, content fingerprint)` rather than the source
@@ -410,10 +379,10 @@ pub struct GlesCanvas
// ─── Drop ──────────────────────────────────────────────────────────────────── // ─── Drop ────────────────────────────────────────────────────────────────────
/// Free this canvas's owned GL resources: FBO, color attachment, aux /// Free this canvas's owned GL resources: FBO, color attachment, aux
/// FBOs if allocated, and any cached glyph / image textures. Shader /// FBOs if allocated, and its gradient textures. Shader programs, the
/// programs and the quad VAO/VBO are shared with sub-canvases and /// quad VAO/VBO and the glyph and image caches are shared with other
/// intentionally NOT deleted here — they leak at process exit, which /// canvases and intentionally NOT deleted here — they leak at process
/// is fine for a process-wide GL context. /// exit, which is fine for a process-wide GL context.
impl Drop for GlesCanvas impl Drop for GlesCanvas
{ {
fn drop( &mut self ) fn drop( &mut self )
@@ -421,8 +390,7 @@ impl Drop for GlesCanvas
// SAFETY: every handle freed below was created through `self.gl` — // SAFETY: every handle freed below was created through `self.gl` —
// either in `setup.rs::new` / `sub_canvas` (`fbo`, `fbo_tex`), // either in `setup.rs::new` / `sub_canvas` (`fbo`, `fbo_tex`),
// `framebuffer.rs::ensure_aux_a` / `ensure_aux_b` (`aux_a`, `aux_b`), // `framebuffer.rs::ensure_aux_a` / `ensure_aux_b` (`aux_a`, `aux_b`),
// `text.rs::draw_text` (`glyph_cache`), `image.rs::draw_image_data` // or `primitives.rs::ensure_lut_texture`
// (`image_cache`), or `primitives.rs::ensure_lut_texture`
// (`gradient_lut_cache`). Each container `drain` / `take` is // (`gradient_lut_cache`). Each container `drain` / `take` is
// consumed once so no double-free is possible. Caller must keep // consumed once so no double-free is possible. Caller must keep
// the GL context current at drop time — this is documented on // 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_framebuffer( fbo );
self.gl.delete_texture( tex ); self.gl.delete_texture( tex );
} }
self.glyph_cache.clear();
self.gl.delete_texture( self.atlas_texture );
for ( _, tex ) in self.gradient_lut_cache.drain() for ( _, tex ) in self.gradient_lut_cache.drain()
{ {
self.gl.delete_texture( tex ); self.gl.delete_texture( tex );
+5 -74
View File
@@ -294,39 +294,7 @@ impl GlesCanvas
( fbo, fbo_tex ) ( fbo, fbo_tex )
}; };
// Atlas format picked per ES profile: GL_R8 / GL_RED on ES3 let glyph_atlas = super::glyph_atlas::shared_glyph_atlas( &gl, version );
// (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 image_cache = super::image::shared_image_cache( &gl ); let image_cache = super::image::shared_image_cache( &gl );
Self Self
{ {
@@ -454,12 +422,7 @@ impl GlesCanvas
u_bd_fc_padding, u_bd_fc_padding,
u_bd_fc_radii, u_bd_fc_radii,
u_bd_fc_tint, u_bd_fc_tint,
atlas_texture, glyph_atlas,
atlas_format,
atlas_cursor_x: 0,
atlas_cursor_y: 0,
atlas_row_height: 0,
glyph_cache: HashMap::new(),
image_cache, image_cache,
gradient_lut_cache: HashMap::new(), gradient_lut_cache: HashMap::new(),
clip_scissor: None, clip_scissor: None,
@@ -487,9 +450,8 @@ impl GlesCanvas
/// back via [`Self::blit`]. /// back via [`Self::blit`].
/// ///
/// The returned canvas inherits the parent's `dpi_scale` and `global_alpha` /// 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 /// (so glyphs render at the same pixel size), and shares the parent's glyph
/// — re-rasterising on first use is the cost of not sharing GL textures /// atlas and image cache.
/// across canvases.
pub fn sub_canvas( &self, width: u32, height: u32 ) -> GlesCanvas pub fn sub_canvas( &self, width: u32, height: u32 ) -> GlesCanvas
{ {
let gl = Arc::clone( &self.gl ); let gl = Arc::clone( &self.gl );
@@ -515,32 +477,6 @@ impl GlesCanvas
( fbo, fbo_tex ) ( 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 GlesCanvas
{ {
gl, gl,
@@ -668,12 +604,7 @@ impl GlesCanvas
u_bd_fc_padding: self.u_bd_fc_padding, u_bd_fc_padding: self.u_bd_fc_padding,
u_bd_fc_radii: self.u_bd_fc_radii, u_bd_fc_radii: self.u_bd_fc_radii,
u_bd_fc_tint: self.u_bd_fc_tint, u_bd_fc_tint: self.u_bd_fc_tint,
atlas_texture, glyph_atlas: std::rc::Rc::clone( &self.glyph_atlas ),
atlas_format,
atlas_cursor_x: 0,
atlas_cursor_y: 0,
atlas_row_height: 0,
glyph_cache: HashMap::new(),
image_cache: std::rc::Rc::clone( &self.image_cache ), image_cache: std::rc::Rc::clone( &self.image_cache ),
gradient_lut_cache: HashMap::new(), gradient_lut_cache: HashMap::new(),
clip_scissor: None, clip_scissor: None,
+1 -1
View File
@@ -183,7 +183,7 @@ void main()
// top-row-first while GL tex coords have V=0 at the bottom. // 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 // 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 // Both formats put the coverage byte in `.r` when sampled, so this
// shader is identical for both. // shader is identical for both.
pub( super ) const GLYPH_FRAG_SRC: &str = r##" pub( super ) const GLYPH_FRAG_SRC: &str = r##"
+51 -72
View File
@@ -1,8 +1,9 @@
// SPDX-License-Identifier: LGPL-2.1-only // SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net> // Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Text rendering for [`GlesCanvas`]: shelf-packed glyph atlas + //! Text rendering for [`GlesCanvas`]: glyphs shelf-packed into the
//! per-glyph draw call. The line is shaped through //! 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 //! [`crate::text_shaping::shape_line`] (Unicode BiDi + per-sub-run
//! rustybuzz / HarfBuzz) and each shaped glyph is rasterised by //! rustybuzz / HarfBuzz) and each shaped glyph is rasterised by
//! glyph index via [`FontFace::rasterize_indexed`], so the cache //! glyph index via [`FontFace::rasterize_indexed`], so the cache
@@ -11,7 +12,7 @@
//! cached correctly without colliding with the source codepoints. //! cached correctly without colliding with the source codepoints.
//! //!
//! The atlas format is picked per ES profile //! 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 //! `GL_LUMINANCE` on ES2. The fragment shader samples `.r` and is
//! identical for both — `GL_RED` puts the coverage byte in `.r` //! identical for both — `GL_RED` puts the coverage byte in `.r`
//! directly, `GL_LUMINANCE` replicates it into `.r=.g=.b`. //! directly, `GL_LUMINANCE` replicates it into `.r=.g=.b`.
@@ -23,7 +24,8 @@ use glow::HasContext;
use crate::types::{ Color, Rect }; 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; const GLYPH_CACHE_SOFT_CAP: usize = 8192;
@@ -58,16 +60,12 @@ impl GlesCanvas
let scaled = size * self.dpi_scale; let scaled = size * self.dpi_scale;
let size_key = ( scaled * 10.0 ) as u32; let size_key = ( scaled * 10.0 ) as u32;
unsafe unsafe { self.gl.active_texture( glow::TEXTURE0 ); }
{
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( self.atlas_texture ) );
}
// Resolve every glyph + its font up front so we can decide // Resolve every glyph + its font up front so we can decide
// whether the atlas needs a single reset before we start // whether the atlas needs a single reset before we start
// emitting quads. Doing the reset mid-draw would orphan the // 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. // already pushed earlier in this same call.
let canvas_handle = self.font_handle(); let canvas_handle = self.font_handle();
let prefer_handle = font.cloned().map( |f| let prefer_handle = font.cloned().map( |f|
@@ -132,6 +130,8 @@ impl GlesCanvas
// already inserted in this same pass) at most once: a single // already inserted in this same pass) at most once: a single
// string longer than the atlas capacity falls back to skipping // string longer than the atlas capacity falls back to skipping
// the tail rather than oscillating. // 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 reset_used = false;
let mut i = 0; let mut i = 0;
while i < shaped.len() while i < shaped.len()
@@ -139,7 +139,7 @@ impl GlesCanvas
let g = &shaped[ i ]; let g = &shaped[ i ];
let glyph_id = g.glyph_id as u16; let glyph_id = g.glyph_id as u16;
let key = ( glyph_id, size_key, g.font_id ); let key = ( glyph_id, size_key, g.font_id );
if self.glyph_cache.contains_key( &key ) if atlas.cache.contains_key( &key )
{ {
i += 1; i += 1;
continue; continue;
@@ -152,11 +152,12 @@ impl GlesCanvas
let ( metrics, bitmap ) = font_arc.rasterize_indexed( glyph_id, scaled ); let ( metrics, bitmap ) = font_arc.rasterize_indexed( glyph_id, scaled );
if metrics.width == 0 || metrics.height == 0 if metrics.width == 0 || metrics.height == 0
{ {
self.glyph_cache.insert( key, GlyphEntry atlas.cache.insert( key, GlyphEntry
{ {
metrics, metrics,
tex_w: 0, tex_w: 0,
tex_h: 0, tex_h: 0,
page: 0,
atlas_x: 0, atlas_x: 0,
atlas_y: 0, atlas_y: 0,
} ); } );
@@ -165,30 +166,31 @@ impl GlesCanvas
} }
let ( w, h ) = ( metrics.width as u32, metrics.height as u32 ); 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; reset_used = true;
i = 0; i = 0;
continue; 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, Some( p ) => p,
None if !reset_used => None if !reset_used =>
{ {
self.atlas_reset(); atlas.reset( &self.gl );
reset_used = true; reset_used = true;
i = 0; i = 0;
continue; continue;
} }
None => None =>
{ {
self.glyph_cache.insert( key, GlyphEntry atlas.cache.insert( key, GlyphEntry
{ {
metrics, metrics,
tex_w: 0, tex_w: 0,
tex_h: 0, tex_h: 0,
page: 0,
atlas_x: 0, atlas_x: 0,
atlas_y: 0, atlas_y: 0,
} ); } );
@@ -196,36 +198,24 @@ impl GlesCanvas
continue; continue;
} }
}; };
unsafe atlas.upload( &self.gl, page, atlas_x, atlas_y, w, h, &bitmap );
{ atlas.cache.insert( key, GlyphEntry
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
{ {
metrics, metrics,
tex_w: w as i32, tex_w: w as i32,
tex_h: h as i32, tex_h: h as i32,
atlas_x: pos.0, page,
atlas_y: pos.1, atlas_x,
atlas_y,
} ); } );
i += 1; i += 1;
} }
// Phase 2 — collect every quad's vertices in one buffer and // Phase 2 — collect the quads' vertices per atlas page and flush
// flush them with a single draw call. Saves N-1 // each page with a single draw call.
// program/uniform/draw round-trips per text run.
let atlas_size = ATLAS_SIZE as f32;
let surface_w = self.width as f32; let surface_w = self.width as f32;
let surface_h = self.height as f32; let surface_h = self.height as f32;
let mut verts: Vec<f32> = Vec::with_capacity( shaped.len() * 24 ); let mut batches: Vec<( usize, Vec<f32> )> = Vec::new();
let mut color_glyphs: Vec<( Arc<crate::font_face::ColorGlyph>, Rect )> = Vec::new(); let mut color_glyphs: Vec<( Arc<crate::font_face::ColorGlyph>, Rect )> = Vec::new();
let mut cx = x; let mut cx = x;
for g in shaped.iter() for g in shaped.iter()
@@ -246,7 +236,7 @@ impl GlesCanvas
cx += g.x_advance; cx += g.x_advance;
continue; continue;
} }
let Some( entry ) = self.glyph_cache.get( &key ) else let Some( entry ) = atlas.cache.get( &key ) else
{ {
cx += g.x_advance; cx += g.x_advance;
continue; continue;
@@ -268,11 +258,21 @@ impl GlesCanvas
let y0_ndc = 1.0 - gy * 2.0 / surface_h; let y0_ndc = 1.0 - gy * 2.0 / surface_h;
let y1_ndc = 1.0 - ( gy + gh ) * 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 ( page_w, page_h ) = atlas.page_size( entry.page );
let v0 = entry.atlas_y as f32 / atlas_size; let u0 = entry.atlas_x as f32 / page_w;
let u1 = ( entry.atlas_x as f32 + gw ) / atlas_size; let v0 = entry.atlas_y as f32 / page_h;
let v1 = ( entry.atlas_y as f32 + gh ) / atlas_size; 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( &[ verts.extend_from_slice( &[
x0_ndc, y0_ndc, u0, v0, x0_ndc, y0_ndc, u0, v0,
x1_ndc, y0_ndc, u1, v0, x1_ndc, y0_ndc, u1, v0,
@@ -285,7 +285,7 @@ impl GlesCanvas
cx += g.x_advance; cx += g.x_advance;
} }
if !verts.is_empty() if !batches.is_empty()
{ {
unsafe unsafe
{ {
@@ -295,12 +295,17 @@ impl GlesCanvas
self.gl.uniform_1_i32( Some( &self.u_glyph_batch_sampler ), 0 ); 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_vertex_array( Some( self.glyph_batch_vao ) );
self.gl.bind_buffer( glow::ARRAY_BUFFER, Some( self.glyph_batch_vbo ) ); 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 ); for ( page, verts ) in &batches
let vertex_count = ( verts.len() / 4 ) as i32; {
self.gl.draw_arrays( glow::TRIANGLES, 0, vertex_count ); 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 ); self.gl.bind_vertex_array( None );
} }
} }
drop( atlas );
unsafe { self.gl.bind_texture( glow::TEXTURE_2D, None ); } 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;
}
} }