use glow::HasContext;
use glam::{Vec2, Vec3, Vec4};
use crate::glyph::{Glyph, GlyphId, RenderLayer, BlendMode};
use crate::glyph::batch::GlyphInstance;
use crate::glyph::atlas::FontAtlas;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RetainedId(pub u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateMode {
Retained,
Immediate,
Auto,
}
const AUTO_PROMOTE_THRESHOLD: u32 = 60;
#[derive(Clone, Debug)]
pub struct RetainedGlyph {
pub glyph: Glyph,
pub id: RetainedId,
pub visible: bool,
pub layer: RenderLayer,
pub generation: u64,
}
#[derive(Debug, Clone)]
struct DirtyRange {
start: usize,
end: usize, }
impl DirtyRange {
fn len(&self) -> usize { self.end - self.start }
}
#[derive(Debug, Clone, Default)]
pub struct RetainedStats {
pub total_glyphs: usize,
pub dirty_count: usize,
pub immediate_count: usize,
pub retained_count: usize,
pub auto_count: usize,
pub auto_promotions: usize,
pub upload_bytes: usize,
pub upload_ranges: usize,
pub dirty_ratio: f32,
pub generation: u64,
}
#[derive(Clone, Debug)]
struct ModeTracker {
mode: UpdateMode,
consecutive_dirty: u32,
}
impl ModeTracker {
fn new(mode: UpdateMode) -> Self {
Self { mode, consecutive_dirty: 0 }
}
fn record_dirty(&mut self) -> bool {
self.consecutive_dirty += 1;
if self.mode == UpdateMode::Auto && self.consecutive_dirty >= AUTO_PROMOTE_THRESHOLD {
self.mode = UpdateMode::Immediate;
return true;
}
false
}
fn record_clean(&mut self) {
self.consecutive_dirty = 0;
}
}
pub struct RetainedScene {
glyphs: Vec<Option<RetainedGlyph>>,
free_slots: Vec<u32>,
next_id: u32,
dirty_flags: Vec<bool>,
mode_trackers: Vec<ModeTracker>,
gpu_buffer: Vec<GlyphInstance>,
full_rebuild_needed: bool,
generation: u64,
pub stats: RetainedStats,
}
impl RetainedScene {
pub fn new(capacity: usize) -> Self {
Self {
glyphs: vec![None; capacity],
free_slots: (0..capacity as u32).rev().collect(),
next_id: 0,
dirty_flags: vec![false; capacity],
mode_trackers: vec![ModeTracker::new(UpdateMode::Auto); capacity],
gpu_buffer: vec![GlyphInstance {
position: [0.0; 3], scale: [0.0; 2], rotation: 0.0,
color: [0.0; 4], emission: 0.0, glow_color: [0.0; 3],
glow_radius: 0.0, uv_offset: [0.0; 2], uv_size: [0.0; 2],
_pad: [0.0; 2],
}; capacity],
full_rebuild_needed: true,
generation: 0,
stats: RetainedStats::default(),
}
}
pub fn insert(&mut self, glyph: Glyph, mode: UpdateMode) -> RetainedId {
let id = RetainedId(self.next_id);
self.next_id += 1;
let slot = if let Some(s) = self.free_slots.pop() {
s as usize
} else {
let s = self.glyphs.len();
self.glyphs.push(None);
self.dirty_flags.push(false);
self.mode_trackers.push(ModeTracker::new(UpdateMode::Auto));
self.gpu_buffer.push(GlyphInstance {
position: [0.0; 3], scale: [0.0; 2], rotation: 0.0,
color: [0.0; 4], emission: 0.0, glow_color: [0.0; 3],
glow_radius: 0.0, uv_offset: [0.0; 2], uv_size: [0.0; 2],
_pad: [0.0; 2],
});
s
};
let layer = glyph.layer;
let visible = glyph.visible;
self.glyphs[slot] = Some(RetainedGlyph {
glyph,
id,
visible,
layer,
generation: self.generation,
});
self.dirty_flags[slot] = true;
self.mode_trackers[slot] = ModeTracker::new(mode);
self.generation += 1;
id
}
pub fn insert_auto(&mut self, glyph: Glyph) -> RetainedId {
self.insert(glyph, UpdateMode::Auto)
}
pub fn insert_immediate(&mut self, glyph: Glyph) -> RetainedId {
self.insert(glyph, UpdateMode::Immediate)
}
pub fn insert_retained(&mut self, glyph: Glyph) -> RetainedId {
self.insert(glyph, UpdateMode::Retained)
}
pub fn remove(&mut self, id: RetainedId) -> Option<Glyph> {
let slot = self.find_slot(id)?;
let retained = self.glyphs[slot].take()?;
self.free_slots.push(slot as u32);
self.dirty_flags[slot] = false;
self.gpu_buffer[slot] = GlyphInstance {
position: [0.0; 3], scale: [0.0; 2], rotation: 0.0,
color: [0.0, 0.0, 0.0, 0.0], emission: 0.0, glow_color: [0.0; 3],
glow_radius: 0.0, uv_offset: [0.0; 2], uv_size: [0.0; 2],
_pad: [0.0; 2],
};
self.full_rebuild_needed = true;
self.generation += 1;
Some(retained.glyph)
}
pub fn get(&self, id: RetainedId) -> Option<&Glyph> {
let slot = self.find_slot(id)?;
self.glyphs[slot].as_ref().map(|r| &r.glyph)
}
pub fn get_mut(&mut self, id: RetainedId) -> Option<&mut Glyph> {
let slot = self.find_slot(id)?;
self.dirty_flags[slot] = true;
self.generation += 1;
self.glyphs[slot].as_mut().map(|r| {
r.generation = self.generation;
&mut r.glyph
})
}
pub fn mark_dirty(&mut self, id: RetainedId) {
if let Some(slot) = self.find_slot(id) {
self.dirty_flags[slot] = true;
self.generation += 1;
}
}
pub fn set_mode(&mut self, id: RetainedId, mode: UpdateMode) {
if let Some(slot) = self.find_slot(id) {
self.mode_trackers[slot].mode = mode;
self.mode_trackers[slot].consecutive_dirty = 0;
}
}
pub fn get_mode(&self, id: RetainedId) -> Option<UpdateMode> {
let slot = self.find_slot(id)?;
Some(self.mode_trackers[slot].mode)
}
pub fn set_position(&mut self, id: RetainedId, pos: Vec3) {
if let Some(glyph) = self.get_mut(id) {
glyph.position = pos;
}
}
pub fn set_color(&mut self, id: RetainedId, color: Vec4) {
if let Some(glyph) = self.get_mut(id) {
glyph.color = color;
}
}
pub fn set_scale(&mut self, id: RetainedId, scale: Vec2) {
if let Some(glyph) = self.get_mut(id) {
glyph.scale = scale;
}
}
pub fn set_visible(&mut self, id: RetainedId, visible: bool) {
if let Some(slot) = self.find_slot(id) {
if let Some(ref mut rg) = self.glyphs[slot] {
rg.visible = visible;
rg.glyph.visible = visible;
}
self.dirty_flags[slot] = true;
self.generation += 1;
}
}
pub fn update(&mut self, atlas: &FontAtlas) -> usize {
let mut dirty_count = 0;
let mut immediate_count = 0;
let mut retained_count = 0;
let mut auto_count = 0;
let mut promotions = 0;
let total = self.glyphs.iter().filter(|g| g.is_some()).count();
for slot in 0..self.glyphs.len() {
let Some(ref rg) = self.glyphs[slot] else { continue };
let tracker = &self.mode_trackers[slot];
match tracker.mode {
UpdateMode::Immediate => {
immediate_count += 1;
self.dirty_flags[slot] = true;
}
UpdateMode::Retained => {
retained_count += 1;
}
UpdateMode::Auto => {
auto_count += 1;
}
}
}
for slot in 0..self.glyphs.len() {
if !self.dirty_flags[slot] {
self.mode_trackers[slot].record_clean();
continue;
}
let Some(ref rg) = self.glyphs[slot] else {
self.dirty_flags[slot] = false;
continue;
};
let glyph = &rg.glyph;
if rg.visible {
let uv = atlas.uv_for(glyph.character);
self.gpu_buffer[slot] = GlyphInstance {
position: glyph.position.to_array(),
scale: [glyph.scale.x, glyph.scale.y],
rotation: glyph.rotation,
color: glyph.color.to_array(),
emission: glyph.emission,
glow_color: glyph.glow_color.to_array(),
glow_radius: glyph.glow_radius,
uv_offset: uv.offset(),
uv_size: uv.size(),
_pad: [0.0; 2],
};
} else {
self.gpu_buffer[slot] = GlyphInstance {
position: [0.0; 3], scale: [0.0; 2], rotation: 0.0,
color: [0.0, 0.0, 0.0, 0.0], emission: 0.0,
glow_color: [0.0; 3], glow_radius: 0.0,
uv_offset: [0.0; 2], uv_size: [0.0; 2],
_pad: [0.0; 2],
};
}
if self.mode_trackers[slot].record_dirty() {
promotions += 1;
}
self.dirty_flags[slot] = false;
dirty_count += 1;
}
self.stats = RetainedStats {
total_glyphs: total,
dirty_count,
immediate_count,
retained_count,
auto_count,
auto_promotions: promotions,
upload_bytes: if self.full_rebuild_needed {
self.gpu_buffer.len() * std::mem::size_of::<GlyphInstance>()
} else {
dirty_count * std::mem::size_of::<GlyphInstance>()
},
upload_ranges: 0, dirty_ratio: if total > 0 { dirty_count as f32 / total as f32 } else { 0.0 },
generation: self.generation,
};
dirty_count
}
pub fn dirty_upload_ranges(&self) -> Vec<(usize, &[u8])> {
if self.full_rebuild_needed {
let bytes = bytemuck::cast_slice(&self.gpu_buffer);
return vec![(0, bytes)];
}
let instance_size = std::mem::size_of::<GlyphInstance>();
let mut ranges: Vec<(usize, &[u8])> = Vec::new();
let mut range_start: Option<usize> = None;
let bytes = bytemuck::cast_slice(&self.gpu_buffer);
vec![(0, bytes)]
}
pub unsafe fn upload_dirty(
&mut self,
gl: &glow::Context,
vbo: glow::Buffer,
atlas: &FontAtlas,
) -> usize {
let dirty_count = self.update(atlas);
let instance_size = std::mem::size_of::<GlyphInstance>();
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
if self.full_rebuild_needed {
let bytes = bytemuck::cast_slice(&self.gpu_buffer);
let total_bytes = bytes.len();
gl.buffer_data_u8_slice(
glow::ARRAY_BUFFER,
bytes,
glow::DYNAMIC_DRAW,
);
self.full_rebuild_needed = false;
self.stats.upload_bytes = total_bytes;
self.stats.upload_ranges = 1;
return total_bytes;
}
if dirty_count == 0 {
self.stats.upload_bytes = 0;
self.stats.upload_ranges = 0;
return 0;
}
let current_gen = self.generation;
let mut uploaded_bytes = 0usize;
let mut range_count = 0usize;
let mut range_start: Option<usize> = None;
let mut range_end: usize = 0;
let gen_threshold = current_gen.saturating_sub(dirty_count as u64);
for slot in 0..self.glyphs.len() {
let is_recent = match &self.glyphs[slot] {
Some(rg) => rg.generation > gen_threshold,
None => false,
};
if is_recent {
match range_start {
None => {
range_start = Some(slot);
range_end = slot + 1;
}
Some(_) => {
if slot <= range_end + 4 {
range_end = slot + 1;
} else {
let start = range_start.unwrap();
let byte_offset = start * instance_size;
let byte_len = (range_end - start) * instance_size;
let slice = &bytemuck::cast_slice::<GlyphInstance, u8>(
&self.gpu_buffer[start..range_end]
);
gl.buffer_sub_data_u8_slice(
glow::ARRAY_BUFFER,
byte_offset as i32,
slice,
);
uploaded_bytes += byte_len;
range_count += 1;
range_start = Some(slot);
range_end = slot + 1;
}
}
}
}
}
if let Some(start) = range_start {
let byte_offset = start * instance_size;
let byte_len = (range_end - start) * instance_size;
let slice = bytemuck::cast_slice::<GlyphInstance, u8>(
&self.gpu_buffer[start..range_end]
);
gl.buffer_sub_data_u8_slice(
glow::ARRAY_BUFFER,
byte_offset as i32,
slice,
);
uploaded_bytes += byte_len;
range_count += 1;
}
self.stats.upload_bytes = uploaded_bytes;
self.stats.upload_ranges = range_count;
uploaded_bytes
}
pub fn needs_full_rebuild(&self) -> bool { self.full_rebuild_needed }
pub fn request_full_rebuild(&mut self) { self.full_rebuild_needed = true; }
pub fn count(&self) -> usize {
self.glyphs.iter().filter(|g| g.is_some()).count()
}
pub fn capacity(&self) -> usize { self.glyphs.len() }
pub fn generation(&self) -> u64 { self.generation }
pub fn gpu_buffer_bytes(&self) -> &[u8] {
bytemuck::cast_slice(&self.gpu_buffer)
}
pub fn gpu_buffer(&self) -> &[GlyphInstance] {
&self.gpu_buffer
}
pub fn gpu_buffer_len(&self) -> usize { self.gpu_buffer.len() }
pub fn iter(&self) -> impl Iterator<Item = (RetainedId, &Glyph)> {
self.glyphs.iter().filter_map(|slot| {
slot.as_ref().map(|rg| (rg.id, &rg.glyph))
})
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = (RetainedId, &mut Glyph)> {
self.generation += 1;
let gen = self.generation;
self.glyphs.iter_mut()
.zip(self.dirty_flags.iter_mut())
.filter_map(move |(slot, dirty)| {
if let Some(ref mut rg) = slot {
*dirty = true;
rg.generation = gen;
Some((rg.id, &mut rg.glyph))
} else {
None
}
})
}
fn find_slot(&self, id: RetainedId) -> Option<usize> {
self.glyphs.iter().position(|slot| {
slot.as_ref().map(|rg| rg.id == id).unwrap_or(false)
})
}
pub fn mark_all_dirty(&mut self) {
for flag in &mut self.dirty_flags {
*flag = true;
}
self.generation += 1;
}
pub fn clear(&mut self) {
for slot in &mut self.glyphs {
*slot = None;
}
self.free_slots = (0..self.glyphs.len() as u32).rev().collect();
for flag in &mut self.dirty_flags {
*flag = false;
}
self.full_rebuild_needed = true;
self.generation += 1;
}
pub fn mode_counts(&self) -> (usize, usize, usize) {
let mut retained = 0;
let mut immediate = 0;
let mut auto = 0;
for (slot, tracker) in self.mode_trackers.iter().enumerate() {
if self.glyphs[slot].is_some() {
match tracker.mode {
UpdateMode::Retained => retained += 1,
UpdateMode::Immediate => immediate += 1,
UpdateMode::Auto => auto += 1,
}
}
}
(retained, immediate, auto)
}
}
pub struct HybridScene {
pub retained: RetainedScene,
immediate_instances: Vec<GlyphInstance>,
combined_buffer: Vec<GlyphInstance>,
pub stats: HybridStats,
}
#[derive(Debug, Clone, Default)]
pub struct HybridStats {
pub retained_stats: RetainedStats,
pub immediate_count: usize,
pub combined_count: usize,
pub retained_upload_bytes: usize,
pub immediate_upload_bytes: usize,
pub total_upload_bytes: usize,
pub bandwidth_saved_pct: f32,
}
impl HybridScene {
pub fn new(retained_capacity: usize) -> Self {
Self {
retained: RetainedScene::new(retained_capacity),
immediate_instances: Vec::with_capacity(4096),
combined_buffer: Vec::with_capacity(retained_capacity + 4096),
stats: HybridStats::default(),
}
}
pub fn begin_frame(&mut self) {
self.immediate_instances.clear();
}
pub fn push_immediate(&mut self, instance: GlyphInstance) {
self.immediate_instances.push(instance);
}
pub fn push_immediate_glyph(&mut self, glyph: &Glyph, atlas: &FontAtlas) {
if !glyph.visible { return; }
let uv = atlas.uv_for(glyph.character);
self.immediate_instances.push(GlyphInstance {
position: glyph.position.to_array(),
scale: [glyph.scale.x, glyph.scale.y],
rotation: glyph.rotation,
color: glyph.color.to_array(),
emission: glyph.emission,
glow_color: glyph.glow_color.to_array(),
glow_radius: glyph.glow_radius,
uv_offset: uv.offset(),
uv_size: uv.size(),
_pad: [0.0; 2],
});
}
pub fn update(&mut self, atlas: &FontAtlas) -> &[GlyphInstance] {
let dirty_count = self.retained.update(atlas);
self.combined_buffer.clear();
for slot in &self.retained.gpu_buffer {
if slot.color[3] > 0.0 || slot.emission > 0.0 {
self.combined_buffer.push(*slot);
}
}
let retained_visible = self.combined_buffer.len();
self.combined_buffer.extend_from_slice(&self.immediate_instances);
let instance_size = std::mem::size_of::<GlyphInstance>();
let retained_upload = self.retained.stats.upload_bytes;
let immediate_upload = self.immediate_instances.len() * instance_size;
let total_upload = retained_upload + immediate_upload;
let full_upload = self.combined_buffer.len() * instance_size;
self.stats = HybridStats {
retained_stats: self.retained.stats.clone(),
immediate_count: self.immediate_instances.len(),
combined_count: self.combined_buffer.len(),
retained_upload_bytes: retained_upload,
immediate_upload_bytes: immediate_upload,
total_upload_bytes: total_upload,
bandwidth_saved_pct: if full_upload > 0 {
(1.0 - total_upload as f32 / full_upload as f32) * 100.0
} else {
0.0
},
};
&self.combined_buffer
}
pub fn combined_bytes(&self) -> &[u8] {
bytemuck::cast_slice(&self.combined_buffer)
}
pub fn total_count(&self) -> usize { self.combined_buffer.len() }
}
pub fn format_retained_stats(stats: &RetainedStats) -> String {
format!(
"Retained: {}/{} dirty ({:.1}%) | modes: R:{} I:{} A:{} | upload: {} bytes ({} ranges) | promos: {} | gen: {}",
stats.dirty_count, stats.total_glyphs, stats.dirty_ratio * 100.0,
stats.retained_count, stats.immediate_count, stats.auto_count,
stats.upload_bytes, stats.upload_ranges,
stats.auto_promotions, stats.generation,
)
}
pub fn format_hybrid_stats(stats: &HybridStats) -> String {
format!(
"Hybrid: {} retained + {} immediate = {} total | upload: {} bytes | saved: {:.1}%",
stats.combined_count - stats.immediate_count,
stats.immediate_count,
stats.combined_count,
stats.total_upload_bytes,
stats.bandwidth_saved_pct,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::glyph::Glyph;
fn test_glyph(ch: char, pos: Vec3) -> Glyph {
Glyph {
character: ch,
position: pos,
visible: true,
..Default::default()
}
}
#[test]
fn insert_and_retrieve() {
let mut scene = RetainedScene::new(64);
let id = scene.insert_auto(test_glyph('A', Vec3::new(1.0, 2.0, 3.0)));
assert_eq!(scene.count(), 1);
let glyph = scene.get(id).unwrap();
assert_eq!(glyph.character, 'A');
assert_eq!(glyph.position, Vec3::new(1.0, 2.0, 3.0));
}
#[test]
fn remove_glyph() {
let mut scene = RetainedScene::new(64);
let id = scene.insert_auto(test_glyph('B', Vec3::ZERO));
assert_eq!(scene.count(), 1);
let removed = scene.remove(id);
assert!(removed.is_some());
assert_eq!(scene.count(), 0);
assert!(scene.get(id).is_none());
}
#[test]
fn mutation_marks_dirty() {
let mut scene = RetainedScene::new(64);
let id = scene.insert_auto(test_glyph('C', Vec3::ZERO));
let atlas = FontAtlas::build(16.0);
scene.update(&atlas);
scene.set_position(id, Vec3::new(5.0, 0.0, 0.0));
let dirty = scene.update(&atlas);
assert_eq!(dirty, 1);
}
#[test]
fn retained_mode_no_upload_when_clean() {
let mut scene = RetainedScene::new(64);
let _id = scene.insert_retained(test_glyph('D', Vec3::ZERO));
let atlas = FontAtlas::build(16.0);
scene.update(&atlas);
assert!(scene.stats.dirty_count > 0);
let dirty = scene.update(&atlas);
assert_eq!(dirty, 0);
assert_eq!(scene.stats.dirty_ratio, 0.0);
}
#[test]
fn immediate_mode_always_dirty() {
let mut scene = RetainedScene::new(64);
let _id = scene.insert_immediate(test_glyph('E', Vec3::ZERO));
let atlas = FontAtlas::build(16.0);
scene.update(&atlas);
let dirty = scene.update(&atlas);
assert_eq!(dirty, 1);
}
#[test]
fn auto_promotes_to_immediate() {
let mut scene = RetainedScene::new(64);
let id = scene.insert_auto(test_glyph('F', Vec3::ZERO));
let atlas = FontAtlas::build(16.0);
assert_eq!(scene.get_mode(id), Some(UpdateMode::Auto));
for _ in 0..AUTO_PROMOTE_THRESHOLD {
scene.mark_dirty(id);
scene.update(&atlas);
}
assert_eq!(scene.get_mode(id), Some(UpdateMode::Immediate));
}
#[test]
fn hybrid_scene_combines_buffers() {
let mut hybrid = HybridScene::new(64);
let atlas = FontAtlas::build(16.0);
hybrid.retained.insert_retained(test_glyph('G', Vec3::ZERO));
hybrid.begin_frame();
hybrid.push_immediate_glyph(&test_glyph('H', Vec3::new(1.0, 0.0, 0.0)), &atlas);
let combined = hybrid.update(&atlas);
assert!(combined.len() >= 2); assert_eq!(hybrid.stats.immediate_count, 1);
}
#[test]
fn generation_increments() {
let mut scene = RetainedScene::new(64);
let gen0 = scene.generation();
let id = scene.insert_auto(test_glyph('I', Vec3::ZERO));
let gen1 = scene.generation();
assert!(gen1 > gen0);
scene.set_position(id, Vec3::ONE);
let gen2 = scene.generation();
assert!(gen2 > gen1);
}
#[test]
fn clear_removes_all() {
let mut scene = RetainedScene::new(64);
scene.insert_auto(test_glyph('J', Vec3::ZERO));
scene.insert_auto(test_glyph('K', Vec3::ONE));
assert_eq!(scene.count(), 2);
scene.clear();
assert_eq!(scene.count(), 0);
}
}