Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 169 additions & 18 deletions crates/rustmotion-core/src/engine/renderer/fonts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,39 @@ use super::google_fonts::{font_cache_dir, resolve_google_font};

thread_local! {
static THREAD_FONT_MGR: FontMgr = FontMgr::default();
static CUSTOM_TYPEFACES: RefCell<HashMap<(String, i32, bool), Typeface>> =
RefCell::new(HashMap::new());
static CUSTOM_TYPEFACES: RefCell<GenerationCache<(String, i32, bool), Typeface>> =
RefCell::new(GenerationCache::new());
static FALLBACK_CACHE: RefCell<GenerationCache<(String, i32, bool, u32), Option<Typeface>>> =
RefCell::new(GenerationCache::new());
}

static FONT_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);

pub fn invalidate_font_caches() {
FONT_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Release);
}

struct GenerationCache<K, V> {
generation: u64,
map: HashMap<K, V>,
}

impl<K: std::hash::Hash + Eq, V> GenerationCache<K, V> {
fn new() -> Self {
Self {
generation: FONT_GENERATION.load(std::sync::atomic::Ordering::Acquire),
map: HashMap::new(),
}
}

fn current(&mut self) -> &mut HashMap<K, V> {
let generation = FONT_GENERATION.load(std::sync::atomic::Ordering::Acquire);
if self.generation != generation {
self.map.clear();
self.generation = generation;
}
&mut self.map
}
}

pub fn font_mgr() -> FontMgr {
Expand All @@ -24,6 +55,7 @@ struct CustomFontVariant {
data: Vec<u8>,
weight: i32,
italic: bool,
generation: u64,
}

fn custom_font_registry() -> &'static Mutex<HashMap<String, Vec<CustomFontVariant>>> {
Expand All @@ -35,16 +67,21 @@ pub fn register_custom_font_variant(family: &str, data: Vec<u8>, weight: i32, it
let mut reg = custom_font_registry()
.lock()
.unwrap_or_else(|e| e.into_inner());
let generation = FONT_GENERATION.load(std::sync::atomic::Ordering::Acquire);
let variants = reg.entry(family.to_string()).or_default();
if !variants
.iter()
.any(|v| v.weight == weight && v.italic == italic)
let replacement = CustomFontVariant {
data,
weight,
italic,
generation,
};
match variants
.iter_mut()
.find(|v| v.weight == weight && v.italic == italic)
{
variants.push(CustomFontVariant {
data,
weight,
italic,
});
Some(existing) if existing.generation == generation => {}
Some(existing) => *existing = replacement,
None => variants.push(replacement),
}
}

Expand Down Expand Up @@ -73,7 +110,7 @@ fn custom_typeface(family: &str, style: FontStyle) -> Option<Typeface> {
let italic = style.slant() != skia_safe::font_style::Slant::Upright;
let cache_key = (family.to_string(), weight, italic);
CUSTOM_TYPEFACES.with(|cache| {
if let Some(tf) = cache.borrow().get(&cache_key) {
if let Some(tf) = cache.borrow_mut().current().get(&cache_key) {
return Some(tf.clone());
}
let data = {
Expand All @@ -85,11 +122,16 @@ fn custom_typeface(family: &str, style: FontStyle) -> Option<Typeface> {
};
let sk_data = skia_safe::Data::new_copy(&data);
let tf = font_mgr().new_from_data(&sk_data, None)?;
cache.borrow_mut().insert(cache_key, tf.clone());
cache.borrow_mut().current().insert(cache_key, tf.clone());
Some(tf)
})
}

#[cfg(test)]
fn what_this_thread_sees_in_its_fallback_cache() -> usize {
FALLBACK_CACHE.with(|cache| cache.borrow_mut().current().len())
}

pub fn resolve_custom_typeface(family: &str, style: FontStyle) -> Option<Typeface> {
custom_typeface(family, style)
}
Expand Down Expand Up @@ -120,6 +162,10 @@ pub fn resolve_font_entry(entry: &FontEntry) -> Result<Vec<std::path::PathBuf>>
}

pub fn load_custom_fonts(fonts: &[FontEntry]) {
if fonts.is_empty() {
return;
}
invalidate_font_caches();
let font_mgr = font_mgr();
for entry in fonts {
match resolve_font_entry(entry) {
Expand Down Expand Up @@ -209,20 +255,16 @@ pub fn fallback_typeface_for_char(
style: FontStyle,
c: char,
) -> Option<Typeface> {
thread_local! {
static FALLBACK_CACHE: RefCell<HashMap<(String, i32, bool, u32), Option<Typeface>>> =
RefCell::new(HashMap::new());
}
let weight = *style.weight();
let italic = style.slant() != skia_safe::font_style::Slant::Upright;
let key = (primary_family.to_string(), weight, italic, c as u32);
FALLBACK_CACHE.with(|cache| {
if let Some(hit) = cache.borrow().get(&key) {
if let Some(hit) = cache.borrow_mut().current().get(&key) {
return hit.clone();
}
let resolved =
font_mgr().match_family_style_character(primary_family, style, &[], c as i32);
cache.borrow_mut().insert(key, resolved.clone());
cache.borrow_mut().current().insert(key, resolved.clone());
resolved
})
}
Expand Down Expand Up @@ -430,3 +472,112 @@ mod tests {
assert_eq!(paths.len(), 1);
}
}

#[cfg(test)]
mod cache_invalidation_reaches_every_thread_tests {
use super::*;
use rayon::prelude::*;
use std::sync::atomic::Ordering;

fn a_face(marker: u8) -> Vec<u8> {
let mut data = vec![
0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x10, 0x00, 0x03, 0x00, 0x04,
];
data.push(marker);
data
}

fn fill_this_threads_fallback_cache() -> usize {
for c in ['a', 'e', 'i', 'o', 'u', 'y', 'z'] {
fallback_typeface_for_char("rm-test-no-such-family", FontStyle::normal(), c);
}
what_this_thread_sees_in_its_fallback_cache()
}

#[test]
fn a_rayon_worker_drops_its_own_copy_when_the_generation_moves() {
let before: Vec<usize> = (0..256)
.into_par_iter()
.map(|_| fill_this_threads_fallback_cache())
.collect();
assert!(
before.iter().any(|&len| len > 0),
"test setup: the workers must actually have filled a thread-local cache, or the \
assertion below proves nothing"
);

invalidate_font_caches();

let after: Vec<usize> = (0..256)
.into_par_iter()
.map(|_| what_this_thread_sees_in_its_fallback_cache())
.collect();
assert!(
after.iter().all(|&len| len == 0),
"the clearing is lazy, so this is what each worker sees on its next lookup — and \
that is the guarantee: clearing on the calling thread alone leaves the threads \
that actually render frames serving the stale face forever: {after:?}"
);
}

#[test]
fn a_generation_cache_holds_its_entries_until_the_generation_moves() {
let mut cache: GenerationCache<u32, u32> = GenerationCache::new();
cache.current().insert(1, 10);
cache.current().insert(2, 20);
assert_eq!(
cache.current().len(),
2,
"nothing moved, nothing is dropped"
);
invalidate_font_caches();
assert_eq!(
cache.current().len(),
0,
"the next access after a bump is what clears it — a thread that is busy rendering \
does not have to be interrupted for its cache to go stale"
);
}

#[test]
fn a_second_file_in_the_same_load_does_not_clobber_the_first() {
let family = "rm-test-same-load";
invalidate_font_caches();
register_custom_font_variant(family, a_face(1), 400, false);
register_custom_font_variant(family, a_face(2), 400, false);
assert_eq!(
custom_font_bytes(family, 400, false),
Some(a_face(1)),
"one FontEntry can resolve to several files; two of them landing on the same weight \
and slant must not fight, the first still wins"
);
}

#[test]
fn the_next_load_supersedes_the_variant_the_previous_one_registered() {
let family = "rm-test-reload";
invalidate_font_caches();
register_custom_font_variant(family, a_face(1), 400, false);

invalidate_font_caches();
register_custom_font_variant(family, a_face(2), 400, false);
assert_eq!(
custom_font_bytes(family, 400, false),
Some(a_face(2)),
"editing the file under --watch is a new load, and its bytes must win — being \
skipped is what kept serving the previous face for the rest of the process"
);
}

#[test]
fn loading_an_empty_font_list_does_not_throw_away_every_threads_cache() {
let generation = FONT_GENERATION.load(Ordering::Acquire);
load_custom_fonts(&[]);
assert_eq!(
FONT_GENERATION.load(Ordering::Acquire),
generation,
"a scenario with no custom fonts reloads constantly under --watch; bumping there \
would clear every worker's font cache for nothing"
);
}
}
1 change: 1 addition & 0 deletions crates/rustmotion/src/cli/commands/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ fn clear_all_media_caches() {
engine::clear_asset_cache();
engine::gif_cache().clear();
engine::video_frame_cache().clear();
rustmotion_core::engine::renderer::invalidate_font_caches();
}

#[allow(clippy::too_many_arguments)]
Expand Down
Loading