Repository: pygfx/pygfx
Pull request #817 (2024-07-11 16:49:40)

Allow a minimum number of fonts to always be cache
I feel like we know we want "at least 1 font". (which is already the case), but I think that users want to load many other fonts (italics and maybe a few more at least).

We use the PyGFX Stats, so that creates "at least 1 font more than our application's font" and the continuous eviction creates random lags.

I also have some experience creating caches, so I spent a bit of time refactoring things. I hope that is "appreciated".

I feel like "20MB of fonts" for a graphics intensive python application is "fine".

Before this, if I would wait 10 seconds, I would get the following evictions:

```
Removing /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf from cache
Removing /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf from cache
```

With the following changes, I no longer get it.

I would say that an LRU cache is likely enough, but the LRU cache might not work very well if the user is flipping through many many fonts quickly

hmaarrfk: maybe i should have not done the refactor, now i have to "fix" the tests ...
hmaarrfk: On main

https://github.com/user-attachments/assets/a340c50a-ee01-4113-951f-b0f4a8e02770

<details><summary> print statements </summary>

```
diff --git a/pygfx/utils/_dirs.py b/pygfx/utils/_dirs.py
index 73e201f..d61c592 100644
--- a/pygfx/utils/_dirs.py
+++ b/pygfx/utils/_dirs.py
@@ -22,7 +22,9 @@ def get_resources_dir():
     with context as path:
         pass
     # Return the dir. We assume that the data files are on a normal dir on the fs.
-    return str(path.parent)
+    directory = str(path.parent)
+    print(directory)
+    return directory
 
 
 def get_cache_dir():
diff --git a/pygfx/utils/text/_shaper.py b/pygfx/utils/text/_shaper.py
index 418ac74..f5e99a3 100644
--- a/pygfx/utils/text/_shaper.py
+++ b/pygfx/utils/text/_shaper.py
@@ -84,6 +84,7 @@ class TemporalCache:
             reverse=True,
         )
         for key, lt in to_remove[self._minimum_font_files :]:
+            print(f"Evicting {key}")
             self._lifetimes.pop(key, None)
             self._cache.pop(key, None)

```
</details>


https://github.com/user-attachments/assets/8dba591b-4be5-4a18-a4f2-663fdd79a44b



Patch:
diff --git a/examples/feature_demo/multiple_fonts.py b/examples/feature_demo/multiple_fonts.py
new file mode 100644
index 0000000..2874500
--- /dev/null
+++ b/examples/feature_demo/multiple_fonts.py
@@ -0,0 +1,63 @@
+"""
+Multiple Fonts
+==============
+
+Example demonstrating the capabilities of text to be rendered in
+multiple different fonts.
+"""
+
+# sphinx_gallery_pygfx_docs = 'screenshot'
+# sphinx_gallery_pygfx_test = 'run'
+
+from wgpu.gui.auto import WgpuCanvas, run
+import pygfx as gfx
+
+from datetime import datetime
+
+now = datetime.now()
+text = now.strftime("%H:%M:%S")
+
+text_noto = gfx.Text(
+    gfx.TextGeometry(
+        text="Noto Sans: " + text,
+        # family="Noto Sans",
+    ),
+    gfx.TextMaterial(color="#B4F8C8", outline_color="#000", outline_thickness=0.15),
+)
+text_noto.local.position = (0, -40, 0)
+
+text_humor = gfx.Text(
+    gfx.TextGeometry(
+        text="Humor Sans: " + text,
+        family="Humor Sans",
+    ),
+    gfx.TextMaterial(color="#B4F8C8", outline_color="#000", outline_thickness=0.15),
+)
+text_humor.local.position = (0, 0, 0)
+
+text_instructions = gfx.Text(
+    gfx.TextGeometry(
+        text="Click to update the clock",
+    ),
+    gfx.TextMaterial(color="#B4F8C8", outline_color="#000", outline_thickness=0.15),
+)
+text_instructions.local.position = (0, 40, 0)
+
+scene = gfx.Scene()
+scene.add(text_noto, text_humor, text_instructions)
+camera = gfx.OrthographicCamera(400, 300)
+renderer = gfx.renderers.WgpuRenderer(WgpuCanvas(size=(800, 600)))
+
+
+@renderer.add_event_handler("pointer_down")
+def change_text(event):
+    now = datetime.now()
+    text = now.strftime("%H:%M:%S")
+    text_noto.geometry.set_text("Noto Sans: " + text)
+    text_humor.geometry.set_text("Humor Sans: " + text, family="Humor Sans")
+    renderer.request_draw()
+
+
+renderer.request_draw(lambda: renderer.render(scene, camera))
+if __name__ == "__main__":
+    run()
diff --git a/pygfx/utils/text/_sdf.py b/pygfx/utils/text/_sdf.py
index 7f5700d..beb2b5d 100644
--- a/pygfx/utils/text/_sdf.py
+++ b/pygfx/utils/text/_sdf.py
@@ -2,7 +2,7 @@ import numpy as np
 import freetype
 
 from ._atlas import glyph_atlas
-from ._shaper import CACHE_FT, REF_GLYPH_SIZE
+from ._shaper import CACHE_FT
 
 
 # A little cache so we can assign numbers to fonts
@@ -38,11 +38,7 @@ def generate_glyph(glyph_indices, font_filename):
     # Get the face object. We will not need it if all glyphs are already
     # in the atlas, but because of the cache it is fast, and this way
     # we keep the face alive in the cache.
-    face = CACHE_FT.get(font_filename)
-    if not face:
-        face = freetype.Face(font_filename)
-        face.set_pixel_sizes(REF_GLYPH_SIZE, REF_GLYPH_SIZE)
-        CACHE_FT.set(font_filename, face)
+    face = CACHE_FT[font_filename]
 
     atlas_indices = np.zeros((len(glyph_indices),), np.uint32)
     for i in range(len(glyph_indices)):
diff --git a/pygfx/utils/text/_shaper.py b/pygfx/utils/text/_shaper.py
index 0d84ec2..5a54b86 100644
--- a/pygfx/utils/text/_shaper.py
+++ b/pygfx/utils/text/_shaper.py
@@ -41,29 +41,34 @@ def shape_text(text, font_filename, direction=None):
 class TemporalCache:
     """A simple cache that drops old items based on time."""
 
-    def __init__(self, lifetime):
+    def __init__(self, lifetime, *, getter, minimum_items=0):
         self._ref_lifetime = lifetime
+        self._minimum_items = minimum_items
         self._cache = {}
         self._lifetimes = {}
+        self._getter = getter
 
-    def get(self, key):
-        """Gets the object corresponding to the given key, or None.
-        A successful get resets the item's lifetime.
+    def __getitem__(self, key):
+        """Gets the object corresponding to the given key.
+
+        Will reset the item's lifetime.
         Getting triggers the lifetimes of all items to be checked.
         """
         try:
             res = self._cache[key]
         except KeyError:
-            res = None
-        else:
-            self._lifetimes[key] = time.time()
+            res = self._getter(key)
+            self._cache[key] = res
+
+        self._lifetimes[key] = time.time()
         self.check_lifetimes()
         return res
 
-    def set(self, key, val):
-        """Store an item in the cache."""
-        self._cache[key] = val
-        self._lifetimes[key] = time.time()
+    def __contains__(self, key):
+        return key in self._cache
+
+    def __len__(self):
+        return len(self._cache)
 
     def check_lifetimes(self):
         """Check the lifetimes of all objects."""
@@ -73,8 +78,13 @@ class TemporalCache:
         # it slower already for 100 items, which is way more realistic
         # font count. So let's keep things simple :)
         pretty_old = time.time() - self._ref_lifetime
-        to_remove = {key for key, lt in self._lifetimes.items() if lt < pretty_old}
-        for key in to_remove:
+        old_items = sorted(
+            ((key, lt) for key, lt in self._lifetimes.items() if lt < pretty_old),
+            key=lambda x: x[1],
+        )
+        max_items_to_remove = max(len(self._cache) - self._minimum_items, 0)
+
+        for key, lt in old_items[:max_items_to_remove]:
             self._lifetimes.pop(key, None)
             self._cache.pop(key, None)
 
@@ -85,8 +95,47 @@ class TemporalCache:
 # a regular dict would probably be fine. But for FT the font can take
 # substantial memory (e.g. a Chinese font can take close to 1MB). This
 # is why we use a temporal cache, that drops unused items after 10s.
-CACHE_HB = TemporalCache(10)
-CACHE_FT = TemporalCache(10)
+
+# However, in many cases, users are probably only using a small
+# subset of fonts so if there are fewer than say 5 fonts,
+# we don't want to continuously evict them.
+# PyGFX uses the vendored Noto fonts (up to 5 ttf files)
+# And the user application may use their own 5 in a basic usecase.
+# So we allow them to load approximately 20 font files without eviction
+
+
+def get_hb_font(font_filename):
+    ref_size = REF_GLYPH_SIZE
+
+    blob = uharfbuzz.Blob.from_file_path(font_filename)
+    face = uharfbuzz.Face(blob)
+    font = uharfbuzz.Font(face)
+    font.scale = ref_size, ref_size
+
+    return blob, face, font
+
+
+CACHE_HB = TemporalCache(
+    lifetime=10,
+    getter=get_hb_font,
+    minimum_items=20,
+)
+
+
+def get_ft_face(font_filename):
+    ref_size = REF_GLYPH_SIZE
+
+    face = freetype.Face(font_filename)
+    face.set_pixel_sizes(ref_size, ref_size)
+
+    return face
+
+
+CACHE_FT = TemporalCache(
+    lifetime=10,
+    getter=get_ft_face,
+    minimum_items=20,
+)
 
 
 def shape_text_hb(text, font_filename, direction=None):
@@ -104,16 +153,8 @@ def shape_text_hb(text, font_filename, direction=None):
         buf.direction = direction
         is_horizontal = direction in ("ltr", "rtl")
 
-    # Load font
-    cached = CACHE_HB.get(font_filename)
-    if cached:
-        blob, face, font = cached
-    else:  # Cache miss
-        blob = uharfbuzz.Blob.from_file_path(font_filename)
-        face = uharfbuzz.Face(blob)
-        font = uharfbuzz.Font(face)
-        font.scale = ref_size, ref_size
-        CACHE_HB.set(font_filename, (blob, face, font))
+    # Load font, maybe from the cache
+    blob, face, font = CACHE_HB[font_filename]
 
     # Shape!
     uharfbuzz.shape(font, buf)
@@ -165,11 +206,7 @@ def shape_text_ft(text, font_filename, direction=None):
     ref_size = REF_GLYPH_SIZE
 
     # Load font face
-    face = CACHE_FT.get(font_filename)
-    if face is None:
-        face = freetype.Face(font_filename)
-        face.set_pixel_sizes(ref_size, ref_size)
-        CACHE_FT.set(font_filename, face)
+    face = CACHE_FT[font_filename]
 
     # With Freetype we simply replace each char for a glyph.
     n_glyphs = len(text)


Test patch:
diff --git a/tests/utils/test_text_shaping.py b/tests/utils/test_text_shaping.py
index 10fcc09..2b460fc 100644
--- a/tests/utils/test_text_shaping.py
+++ b/tests/utils/test_text_shaping.py
@@ -7,59 +7,141 @@ from pygfx.utils.text import font_manager
 
 
 def test_cache():
-    c = TemporalCache(0.1)
+    c = TemporalCache(0.1, getter=hash)
 
     # === Store one value
-    c.set("foo", 42)
-    assert c.get("foo") == 42
-    assert c.get("foo") == 42
-    assert len(c._cache) == 1
+    assert c["foo"] == hash("foo")
+    assert len(c) == 1
 
     # Wait for the entry to become old.
+    # The item should remain in there since
+    # no other item was fetched
+    time.sleep(0.11)
+    assert "foo" in c
     # But because we get the item, it activates again
+    # And it won't get evicted when we fetch bar after
+    # it
+    assert c["foo"] == hash("foo")
+    assert c["bar"] == hash("bar")
+    assert "foo" in c
+    assert "bar" in c
+
+    # Wait again, now get a previously nonexistent item to trigger removing foo
     time.sleep(0.11)
-    assert c.get("foo") == 42
-    assert c.get("foo") == 42
+    assert c["bar"] == hash("bar")
+    assert "foo" not in c
+    assert "bar" in c
+    assert len(c) == 1
 
-    # Wait again, now get a nonexistent item to trigger removing foo
     time.sleep(0.11)
-    assert c.get("bar") is None
-    assert c.get("foo") is None
-    assert len(c._cache) == 0
-
     # === Again, but with two
-    c.set("foo", 42)
-    c.set("bar", 90)
-    assert c.get("foo") == 42
-    assert c.get("bar") == 90
-    assert len(c._cache) == 2
+    assert c["foo"] == hash("foo")
+    assert c["bar"] == hash("bar")
+    assert len(c) == 2
 
     # Wait for the entry to become old.
     # The first one we get() is valid, but the get triggers a cleanup.
     time.sleep(0.11)
-    assert c.get("foo") == 42
-    assert c.get("bar") is None
-    assert len(c._cache) == 1
+    assert c["foo"] == hash("foo")
+    assert "bar" not in c
+    assert len(c) == 1
 
-    # Wait again, now get a nonexistent item to trigger removing foo
+    # Wait again, now manually trigger cleanup
     time.sleep(0.11)
-    assert c.get("bar") is None
-    assert c.get("foo") is None
-    assert len(c._cache) == 0
+    c.check_lifetimes()
+    assert "foo" not in c
+    assert "bar" not in c
+    assert len(c) == 0
 
     # === Put a lot of items in there
     for i in range(1000):
-        c.set(i, i)
-    assert len(c._cache) == 1000
+        c[i]
+    assert len(c) == 1000
 
     for i in [0, 1, 500, 998, 999]:  # testing 1000 can take too long in debug mode
-        assert c.get(i) == i
+        assert c[i] == hash(i)
 
     time.sleep(1.1)
-    assert c.get(0) == 0
+    assert c[0] == hash(0)
     for i in range(1, 1000):
-        assert c.get(i) is None
-    assert len(c._cache) == 1
+        assert i not in c
+    assert len(c) == 1
+
+
+def test_cache_minimum_items():
+    c = TemporalCache(0.1, getter=hash, minimum_items=5)
+
+    assert c["foo0"] == hash("foo0")
+    assert c["foo1"] == hash("foo1")
+    assert c["foo2"] == hash("foo2")
+    assert c["foo3"] == hash("foo3")
+    assert c["foo4"] == hash("foo4")
+
+    # Wait for the entry to become old.
+    # The item should remain in there since
+    # no other item was fetched
+    time.sleep(0.11)
+    assert "foo0" in c
+    assert "foo1" in c
+    assert "foo2" in c
+    assert "foo3" in c
+    assert "foo4" in c
+
+    # Typically, without minimum_items set to 2, the following would
+    # cause an eviction
+    assert c["foo4"] == hash("foo4")
+    # However, we expect the fiels to stay in
+    assert "foo0" in c
+    assert "foo1" in c
+    assert "foo2" in c
+    assert "foo3" in c
+    assert "foo4" in c
+
+    time.sleep(0.11)
+    # Even manually clearing shouldn't remove our 5 files
+    c.check_lifetimes()
+    assert "foo0" in c
+    assert "foo1" in c
+    assert "foo2" in c
+    assert "foo3" in c
+    assert "foo4" in c
+
+    # Adding 2 new items in the cache should evict the 2 oldest ones
+    time.sleep(0.11)
+    assert c["bar0"] == hash("bar0")
+    assert c["bar1"] == hash("bar1")
+    # foo0 and foo1 were inserted first
+    # never accessed again, so they should be evicted
+    assert "foo0" not in c
+    assert "foo1" not in c
+    # foo2, foo3 and foo4 should still be in the cache
+    # given that we want to keep at least 5 items
+    assert "foo2" in c
+    assert "foo3" in c
+    assert "foo4" in c
+
+    # Now access all the items in the cache
+    # And have more than 5 items
+    # since no item is older than the lifetime, they should all
+    # remain in the cache
+    assert len(c) == 5
+    # Refresh all the lifetimes
+    assert c["foo2"] == hash("foo2")
+    assert c["foo3"] == hash("foo3")
+    assert c["foo4"] == hash("foo4")
+    assert c["bar0"] == hash("bar0")
+    assert c["bar1"] == hash("bar1")
+    # New items to add
+    assert c["foo0"] == hash("foo0")
+    assert c["foo1"] == hash("foo1")
+    assert len(c) == 7
+    assert "foo2" in c
+    assert "foo3" in c
+    assert "foo4" in c
+    assert "bar0" in c
+    assert "bar1" in c
+    assert "foo0" in c
+    assert "foo1" in c
 
 
 def test_shape_text_hb():