Repository: pygfx/pygfx
Pull request #1006 (2025-02-28 16:12:48)

Text performance tweaks
## Benchmarks

I've written a few benchmarks in https://github.com/pygfx/pygfx-benchmarks/pull/7.

Before new text:
```
               large_static_text (10x) - cpu:  2.58 ms
   changing_one_word_in_big_text (10x) - cpu: 17.85 ms
 changing_one_word_in_small_text (1000x) - cpu:  0.08 ms
           multiple_text_objects (10x) - cpu: 20.05 ms
              multitext_geometry (10x) - cpu:  -
```

After new text (#834):
```
              large_static_text (10x) - cpu:  2.52 ms
  changing_one_word_in_big_text (10x) - cpu:  0.66 ms
changing_one_word_in_small_text (1000x) - cpu:  0.12 ms
          multiple_text_objects (10x) - cpu: 22.16 ms
             multitext_geometry (10x) - cpu:  2.86 ms
```

We can see that rendering a big static text does not make a significant difference.
The extra lookup in the positions buffer does not seem to affect the rendering performance.

Changing a single word in a big text is now *much* more efficient, because of how text blocks and text items are re-used, and re-layout mostly confined to the changed paragraph. And this is with improved layout (e.g. `line_width`).

Updating a short piece of text is measurable slower. This is one of the things I try to improve in this PR.

Using `MultiTextGeometry` is a lot faster than rendering separate text objects, as expected. (Tested with 1000, but the improvement is likely observable with much smaller numbers.)

After this PR:
```
changing_one_word_in_small_text (1000x) - cpu:  0.07 ms
```
A tiny number but (with this number of iters) quite stable, so a 40% speedup.


## What I did

I ran one of the benchmarks in isolation to test the performance as I tweaked the code, and used cprofile to identify bottlenecks: 
```
python -m cProfile -o program.prof my_little_benchmark_script.py && python -m snakeviz program.prof
```

## Tasks

In this PR I focus on optimizing the setting of text. 

* [x] Added an example to render a large piece of text.
* [x] Tweaks for performance related to setting text:
  * [x] tokenizer
  * [x] shaping
  * [x] font selection    
  * [x] some general tweaks
  * [x] text item syncing
  * [x] layout 

Directions for future optimizations:
* Avoid attribute access of geometry, which is expensive due to the custom `__getattr__`.
* Pack per-glyph info into a single buffer.

Korijn: Just out of curiosity: are you using a profiler?
almarklein: Ready from my end.
Korijn: The docs build is failing, so we can't merge before that's addressed.
almarklein: I missed that. Fixed.

Patch:
diff --git a/examples/feature_demo/text_big.py b/examples/feature_demo/text_big.py
new file mode 100644
index 0000000..33c5f7b
--- /dev/null
+++ b/examples/feature_demo/text_big.py
@@ -0,0 +1,65 @@
+"""
+Big static text
+===============
+
+Render 10 paragraphs of Lorem ipsum.
+"""
+
+# sphinx_gallery_pygfx_docs = 'screenshot'
+# sphinx_gallery_pygfx_test = 'run'
+
+from rendercanvas.auto import RenderCanvas, loop
+
+import pygfx as gfx
+
+
+canvas = RenderCanvas(
+    size=(800, 600), title="Big static text $fps", update_mode="continuous"
+)
+renderer = gfx.renderers.WgpuRenderer(canvas)
+
+scene = gfx.Scene()
+
+
+scene.add(gfx.Background.from_color("#fff"))
+
+text = """
+Lorem ipsum odor amet, consectetuer adipiscing elit. Congue aliquet fusce hendrerit leo fames ac. Proin nec sit mauris lobortis quam ultrices. Senectus habitasse ad orci posuere fusce. Ut lectus inceptos commodo taciti porttitor a habitasse. Vulputate tempus ullamcorper aptent molestie vestibulum massa. Tristique nec ac sagittis morbi; egestas nisl donec morbi. Et nisi donec conubia duis rutrum tellus?
+
+Amet praesent proin leo morbi dignissim eu et sagittis. Finibus dictum parturient eleifend in interdum class mauris. Nisi mollis est per; aliquet primis interdum. Semper torquent sem aliquam eleifend id malesuada. Magnis tincidunt suspendisse sapien massa etiam aptent libero morbi. Commodo montes ac torquent quam rutrum. Ante dis potenti nostra maximus non mattis. Interdum fringilla suspendisse parturient accumsan habitant ridiculus proin. Cursus dolor facilisis arcu ultricies facilisi velit habitasse natoque.
+
+Netus libero nascetur senectus habitant viverra cras euismod maximus. Mattis praesent ornare morbi donec commodo. Natoque ante taciti primis habitasse malesuada montes euismod. Quisque id faucibus, condimentum vehicula montes fusce sem. Eu congue malesuada justo a nulla suscipit. Aplatea curabitur et eget felis, at aptent velit. Ultricies id viverra vestibulum eleifend, massa sed nec. Magnis nullam curae urna quam condimentum facilisis eu. Euismod euismod phasellus, tempus inceptos risus accumsan morbi. Curae metus libero volutpat nisi quis facilisi montes.
+
+Netus egestas magnis cras habitasse porta egestas fringilla. Tortor lectus tortor iaculis eu arcu ex sollicitudin quis. Nascetur dis malesuada ad dapibus, lobortis maximus leo inceptos eros. Vel auctor libero iaculis aliquet fringilla leo donec; curabitur eleifend. Quis laoreet placerat cursus dui sapien finibus. Mi gravida dapibus ante libero tristique auctor lobortis. Primis litora vivamus enim blandit sed.
+
+Nisl eleifend ante mus fusce; feugiat habitasse montes? Volutpat convallis ullamcorper viverra quisque neque porttitor sit? Tempus ut ligula ex maximus ultricies justo erat tempus. Risus bibendum malesuada vitae nulla erat fermentum luctus. Viverra enim nisi cursus sodales tortor volutpat eu? Porta libero mauris ligula elementum facilisi fermentum dignissim. Himenaeos convallis nunc viverra ligula litora mauris tempor etiam? Sem accumsan nibh adipiscing proin sagittis hendrerit suscipit dignissim.
+
+Sapien fringilla facilisis nisl neque leo justo natoque quam. Integer vehicula suspendisse vehicula metus hac rhoncus sed. Lectus felis torquent suspendisse ullamcorper sem est tellus habitasse cras. Dictumst justo nunc vitae quisque facilisis. Vitae bibendum habitant semper nullam purus tellus phasellus faucibus. Arcu nascetur penatibus efficitur ridiculus nullam elit. Mauris nostra ridiculus nibh bibendum tempus sollicitudin ipsum non eleifend.
+
+Ultrices himenaeos litora cubilia risus magna hac. Nisi donec sit praesent pellentesque molestie taciti dui. Laoreet tristique odio natoque elementum duis platea posuere. Dolor adipiscing ligula posuere sodales cubilia, taciti condimentum dignissim. Viverra eget class torquent fusce condimentum. Conubia laoreet viverra dictumst habitant, natoque potenti senectus. Sem leo diam suscipit massa faucibus congue. Faucibus cras ad leo pretium augue iaculis ex mus. Quisque malesuada torquent elit curabitur mauris eleifend? Viverra interdum enim dictum curae fringilla potenti felis dolor duis.
+
+Vitae maecenas varius justo egestas eleifend finibus congue. Orci eu taciti sagittis mus nibh congue urna conubia. Habitant duis nec vulputate orci congue quam. Est sem amet nulla blandit eget. Litora ut est lobortis natoque commodo; eros molestie rhoncus. Inceptos commodo lectus ante porttitor primis duis libero inceptos. Ipsum convallis libero torquent mattis etiam sed finibus. Hac leo ligula; dis fames molestie elit. Cras ridiculus euismod sollicitudin sodales nec ante fusce tristique.
+
+Elit curabitur condimentum eros faucibus rhoncus faucibus taciti. Congue enim consequat venenatis torquent ante senectus neque tortor tincidunt. Odio litora nisl pharetra magnis arcu lacus. Vulputate amet velit nascetur arcu sociosqu erat scelerisque. Habitant ipsum dis faucibus eros tempor tellus. Eleifend class ex non consequat nunc conubia et hendrerit ligula. Porttitor vulputate dolor phasellus efficitur nisi viverra. Orci ridiculus senectus sit nostra conubia purus sociosqu mauris. Elementum nisl tristique magnis vulputate enim efficitur. Dignissim phasellus integer quisque dapibus sollicitudin conubia montes risus.
+
+Eleifend imperdiet primis quam conubia sapien lacus nec sagittis curabitur. Dapibus ante laoreet integer euismod sit ad nulla ultricies. Aenean neque libero lacus ut mollis mus? Cubilia nascetur tempus ultrices vel cursus ipsum egestas vivamus varius. Tellus nostra tristique quis ante felis. Per maximus odio eget nisl ornare; faucibus risus natoque. Curae dapibus interdum finibus adipiscing dui duis convallis eleifend. Ligula ornare himenaeos dictumst felis nam tempor tempor. Curae penatibus tortor vel adipiscing quam nec penatibus himenaeos.
+"""
+
+material = gfx.TextMaterial(color="#000")
+
+tob = gfx.Text(gfx.TextGeometry(text=text, max_width=1000), material)
+scene.add(tob)
+
+camera = gfx.OrthographicCamera(900, 900)
+controller = gfx.PanZoomController(camera, register_events=renderer)
+
+
+def animate():
+    renderer.render(scene, camera)
+
+
+renderer.request_draw(animate)
+
+
+if __name__ == "__main__":
+    loop.run()
diff --git a/pygfx/geometries/_base.py b/pygfx/geometries/_base.py
index a4e484f..7c67b2c 100644
--- a/pygfx/geometries/_base.py
+++ b/pygfx/geometries/_base.py
@@ -135,11 +135,11 @@ class Geometry(Trackable):
                     # Construct aabb
                     pos_finite = pos[finite_mask]
                     aabb = np.array(
-                        [pos_finite.min(axis=0), pos_finite.max(axis=0)], np.float32
+                        [pos_finite.min(axis=0), pos_finite.max(axis=0)], "f4"
                     )
                     # If positions contains xy, but not z, assume z=0
                     if aabb.shape[1] == 2:
-                        aabb = np.column_stack([aabb, np.zeros((2, 1), np.float32)])
+                        aabb = np.column_stack([aabb, np.zeros((2, 1), "f4")])
             self._aabb = aabb
             self._aabb_rev = self.positions.rev
             return self._aabb
@@ -200,12 +200,10 @@ class Geometry(Trackable):
                     distances = np.linalg.norm(pos_finite - center, axis=0)
                     radius = float(distances.max())
                     if len(center) == 2:
-                        bsphere = np.array(
-                            [center[0], center[1], 0.0, radius], np.float32
-                        )
+                        bsphere = np.array([center[0], center[1], 0.0, radius], "f4")
                     else:
                         bsphere = np.array(
-                            [center[0], center[1], center[2], radius], np.float32
+                            [center[0], center[1], center[2], radius], "f4"
                         )
             self._bsphere = bsphere
             self._bsphere_rev = positions.rev
diff --git a/pygfx/geometries/_text.py b/pygfx/geometries/_text.py
index 94b3338..eb6a4c9 100644
--- a/pygfx/geometries/_text.py
+++ b/pygfx/geometries/_text.py
@@ -55,23 +55,15 @@ WHITESPACE_EXTENTS = {}
 class TextEngine:
     """A small abstraction that allows subclasses of TextGeometry to use a different text engine."""
 
-    def select_font(self, text, font_props):
-        """The font selection step. Returns (text, font_filename) tuples.
-        Can be overloaded for custom behavior.
-        """
-        return textmodule.select_font(text, font_props)
+    def __init__(self):
+        # The shaping step, returns (glyph_indices, positions, meta)
+        self.shape_text = textmodule._shaper.shape_text_hb
 
-    def shape_text(self, text, font_filename, direction):
-        """The shaping step. Returns (glyph_indices, positions, meta).
-        Can be overloaded for custom behavior.
-        """
-        return textmodule.shape_text(text, font_filename, direction)
+        # The font selection step, returns (text, font_filename) tuples.
+        self.select_font = textmodule.select_font
 
-    def generate_glyph(self, glyph_indices, font_filename):
-        """The glyph generation step. Returns an array with atlas indices.
-        Can be overloaded for custom behavior.
-        """
-        return textmodule.generate_glyph(glyph_indices, font_filename)
+        # The glyph generation step. Returns an array with atlas indices.
+        self.generate_glyph = textmodule.generate_glyph
 
     def get_ws_extent(self, ws, font, direction):
         """Get the extent of a piece of whitespace text. Results of small strings are cached."""
@@ -177,18 +169,18 @@ class TextGeometry(Geometry):
         # --- create per-item arrays/buffers
 
         # The position of each text block
-        self.positions = Buffer(np.zeros((8, 3), np.float32))
-        # self.colors = Buffer(np.zeros((8,4), np.float32))-> we could later implement per-block colors
+        self.positions = Buffer(np.zeros((8, 3), "f4"))
+        # self.colors = Buffer(np.zeros((8,4), "f4"))-> we could later implement per-block colors
 
         # --- create per-glyph arrays/buffers
 
         # Index into the atlas that contains all glyphs
-        self.glyph_atlas_indices = Buffer(np.zeros((16,), np.uint32))
+        self.glyph_atlas_indices = Buffer(np.zeros((16,), "u4"))
         # Index into the block list above (i.e. the block.index)
-        self.glyph_block_indices = Buffer(np.zeros((16,), np.uint32))
+        self.glyph_block_indices = Buffer(np.zeros((16,), "u4"))
         # Sub-position for glyph size, shaping, kerning, etc.
-        self.glyph_positions = Buffer(np.zeros((16, 2), np.float32))
-        self.glyph_sizes = Buffer(np.zeros((16,), np.float32))
+        self.glyph_positions = Buffer(np.zeros((16, 2), "f4"))
+        self.glyph_sizes = Buffer(np.zeros((16,), "f4"))
 
         # --- init variables to help manage the glyph arrays
 
@@ -206,7 +198,7 @@ class TextGeometry(Geometry):
 
         # --- other geomery-specific things
 
-        self._aabb = np.zeros((2, 3), np.float32)
+        self._aabb = np.zeros((2, 3), "f4")
 
         # --- set propss
 
@@ -525,7 +517,7 @@ class TextGeometry(Geometry):
             # There is no sensible bounding box for text in screen space, except
             # for the anchor point. Although the point has no volume, it does
             # contribute to e.g. the scene's bounding box.
-            return np.zeros((2, 3), np.float32)
+            return np.zeros((2, 3), "f4")
         else:
             # A bounding box makes sense, and we calculated it during layout,
             # because we're already shifting rects there.
@@ -537,12 +529,12 @@ class TextGeometry(Geometry):
             # There is no sensible bounding box for text in screen space, except
             # for the anchor point. Although the point has no volume, it does
             # contribute to e.g. the scene's bounding box.
-            return np.zeros((4,), np.float32)
+            return np.zeros((4,), "f4")
         else:
             # A bounding box makes sense, we can calculate it from the rect.
             mean = 0.5 * (self._aabb[1] + self._aabb[0])
             diag = np.norm(self._aabb[1] - self._aabb[0])
-            return np.array([[mean[0], mean[1], mean[2], diag]], np.float32)
+            return np.array([[mean[0], mean[1], mean[2], diag]], "f4")
 
     # --- private methods
 
@@ -581,7 +573,7 @@ class TextGeometry(Geometry):
                 (total_rect.left, total_rect.bottom, 0),
                 (total_rect.right, total_rect.top, 0),
             ],
-            np.float32,
+            "f4",
         )
 
     # --- block management
@@ -595,23 +587,25 @@ class TextGeometry(Geometry):
 
         # Make sure the underlying buffers are large enough
         current_buffer_size = self.positions.nitems
-        if current_buffer_size < n or current_buffer_size > 4 * n:
+        max_oversize = max(4 * n, 8)
+        if current_buffer_size < n or current_buffer_size > max_oversize:
             new_size = 2 ** int(np.ceil(np.log2(max(n, 1))))
             new_size = max(8, new_size)
             self._allocate_block_buffers(new_size)
 
-        # Add or remove blocks
-        while len(self._text_blocks) > n:
-            block = self._text_blocks.pop(-1)
-            block._clear(self)
-        while len(self._text_blocks) < n:
-            block = TextBlock(len(self._text_blocks), self._dirty_blocks)
-            self._text_blocks.append(block)
+        if n != len(self._text_blocks):
+            # Add or remove blocks
+            while len(self._text_blocks) > n:
+                block = self._text_blocks.pop(-1)
+                block._clear(self)
+            while len(self._text_blocks) < n:
+                block = TextBlock(len(self._text_blocks), self._dirty_blocks)
+                self._text_blocks.append(block)
 
     def _allocate_block_buffers(self, n):
         """Allocate new buffers for text blocks with the given size."""
         smallest_n = min(n, len(self._text_blocks))
-        new_positions = np.zeros((n, 3), np.float32)
+        new_positions = np.zeros((n, 3), "f4")
         new_positions[:smallest_n] = self.positions.data[:smallest_n]
         self.positions = Buffer(new_positions)
 
@@ -634,7 +628,7 @@ class TextGeometry(Geometry):
             self._glyph_indices_top += n
         else:
             # First use gaps ...
-            indices = np.empty((n,), np.uint32)
+            indices = np.empty((n,), "u4")
             n_from_gap = min(n, len(self._glyph_indices_gaps))
             for i in range(n_from_gap):
                 indices[i] = self._glyph_indices_gaps.pop()
@@ -682,10 +676,10 @@ class TextGeometry(Geometry):
         new_size = max(16, new_size)
 
         # Prepare new arrays
-        glyph_block_indices = np.zeros((new_size,), np.uint32)
-        glyph_atlas_indices = np.zeros((new_size,), np.uint32)
-        glyph_positions = np.zeros((new_size, 2), np.float32)
-        glyph_sizes = np.zeros((new_size,), np.float32)
+        glyph_block_indices = np.zeros((new_size,), "u4")
+        glyph_atlas_indices = np.zeros((new_size,), "u4")
+        glyph_positions = np.zeros((new_size, 2), "f4")
+        glyph_sizes = np.zeros((new_size,), "f4")
 
         # Copy data over
         n = self._glyph_indices_top
@@ -765,7 +759,7 @@ class MultiTextGeometry(TextGeometry):
                 (total_rect.left, total_rect.bottom, 0),
                 (total_rect.right, total_rect.top, 0),
             ],
-            np.float32,
+            "f4",
         )
 
     def get_bounding_box(self):
@@ -778,10 +772,10 @@ class MultiTextGeometry(TextGeometry):
             aabb = None
             # Get positions and check expected shape
             positions = self.positions.data[: len(self._text_blocks)]
-            aabb = np.array([positions.min(axis=0), positions.max(axis=0)], np.float32)
+            aabb = np.array([positions.min(axis=0), positions.max(axis=0)], "f4")
             # If positions contains xy, but not z, assume z=0
             if aabb.shape[1] == 2:
-                aabb = np.column_stack([aabb, np.zeros((2, 1), np.float32)])
+                aabb = np.column_stack([aabb, np.zeros((2, 1), "f4")])
             self._aabb = aabb
             self._aabb_rev = self.positions.rev
             return self._aabb
@@ -798,13 +792,13 @@ class MultiTextGeometry(TextGeometry):
             distances = np.linalg.norm(positions - center, axis=0)
             radius = float(distances.max())
             if len(center) == 2:
-                return np.array([center[0], center[1], 0.0, radius], np.float32)
+                return np.array([center[0], center[1], 0.0, radius], "f4")
             else:
-                return np.array([center[0], center[1], center[2], radius], np.float32)
+                return np.array([center[0], center[1], center[2], radius], "f4")
         else:
             mean = 0.5 * (self._aabb[1] + self._aabb[0])
             diag = np.norm(self._aabb[1] - self._aabb[0])
-            return np.array([[mean[0], mean[1], mean[2], diag]], np.float32)
+            return np.array([[mean[0], mean[1], mean[2], diag]], "f4")
 
 
 class TextBlock:
@@ -849,8 +843,9 @@ class TextBlock:
 
         # Update position?
         if self._pending_position is not None:
-            geometry.positions.data[self.index] = self._pending_position
-            geometry.positions.update_indices(self.index)
+            positions = geometry.positions
+            positions.data[self.index] = self._pending_position
+            positions.update_indices(self.index)
             self._pending_position = None
 
         # Reset flags
@@ -1014,9 +1009,6 @@ class TextBlock:
         pending_pieces = []
         new_items = []
 
-        def add_piece(format, text):
-            pending_pieces.append((format, text))
-
         def flush_pieces(force=False):
             nonlocal pending_whitespace
             if pending_pieces or force:
@@ -1074,7 +1066,7 @@ class TextBlock:
                     flush_pieces(force=True)
                 new_items[-1].nl_after += text
             else:
-                add_piece(format.copy(), text)
+                pending_pieces.append((format.copy(), text))
 
         flush_pieces()
         if pending_whitespace:
@@ -1134,7 +1126,7 @@ class TextItem:
         self.glyph_count = 0
 
         # The indices for slots in the arrays at the geometry. This value is managed by the geometry.
-        self.glyph_indices = np.zeros((0,), np.uint32)
+        self.glyph_indices = np.zeros((0,), "u4")
 
         # Transform info when copying to he geometry buffers. Set during layout.
         self.offset = (1.0, 0.0, 0.0)
@@ -1162,6 +1154,10 @@ class TextItem:
     def render_glyphs(self, geometry):
         """Update the item's arrays."""
 
+        # TODO: The methods on this class are hot code. They access
+        # attributes fromgeometry quite a few times. Unfortunately this access
+        # is expensive, because of the custom __getattr__. Would be good to store
+        # less stuff on the geometry, and more on another object.
         self.need_render_glyphs = False
         self.need_sync_with_geometry = True
 
@@ -1213,7 +1209,7 @@ class TextItem:
                     positions *= rsize
                 if extent:
                     positions[:, 0] += extent  # put pieces next to each-other
-                sizes = np.full((positions.shape[0],), rsize, np.float32)
+                sizes = np.full((positions.shape[0],), rsize, "f4")
                 extent = extent + meta["extent"] * rsize
                 ascender = max(ascender, meta["ascender"] * rsize)
                 descender = min(descender, meta["descender"] * rsize)  # is neg
@@ -1285,7 +1281,7 @@ class TextItem:
             extra_indices = geometry._glyphs_allocate(
                 glyph_count - current_glyph_indices_count
             )
-            new_indices = np.empty((glyph_count,), np.uint32)
+            new_indices = np.empty((glyph_count,), "u4")
             new_indices[:current_glyph_indices_count] = glyph_indices
             new_indices[current_glyph_indices_count:] = extra_indices
             self.glyph_indices = new_indices
@@ -1293,27 +1289,35 @@ class TextItem:
     def _sync_data(self, geometry, block_index):
         indices = self.glyph_indices
 
+        # TODO: I think we may optimize by combining arrays from multiple glyphs
+
         # Make the positioning absolute
         scale, dx, dy = self.offset
         positions = self.positions * scale + (dx, dy)
         sizes = self.sizes * scale
 
+        # Get buffers from geometry (the getattr is a bit expensive)
+        glyph_block_indices = geometry.glyph_block_indices
+        glyph_atlas_indices = geometry.glyph_atlas_indices
+        glyph_positions = geometry.glyph_positions
+        glyph_sizes = geometry.glyph_sizes
+
         # Write glyph data
-        geometry.glyph_block_indices.data[indices] = block_index
-        geometry.glyph_atlas_indices.data[indices] = self.atlas_indices
-        geometry.glyph_positions.data[indices] = positions
-        geometry.glyph_sizes.data[indices] = sizes
+        glyph_block_indices.data[indices] = block_index
+        glyph_atlas_indices.data[indices] = self.atlas_indices
+        glyph_positions.data[indices] = positions
+        glyph_sizes.data[indices] = sizes
 
         # Trigger sync
-        geometry.glyph_block_indices.update_indices(indices)
-        geometry.glyph_atlas_indices.update_indices(indices)
-        geometry.glyph_positions.update_indices(indices)
-        geometry.glyph_sizes.update_indices(indices)
+        glyph_block_indices.update_indices(indices)
+        glyph_atlas_indices.update_indices(indices)
+        glyph_positions.update_indices(indices)
+        glyph_sizes.update_indices(indices)
 
     def clear(self, geometry):
         if len(self.glyph_indices):
             geometry._glyphs_deallocate(self.glyph_indices)
-            self.glyph_indices = np.zeros((0,), np.uint32)
+            self.glyph_indices = np.zeros((0,), "u4")
 
 
 def encode_font_props_in_atlas_indices(atlas_indices, weight, slant):
@@ -1359,7 +1363,7 @@ class Rect:
         self.bottom = self.bottom + dy
 
     def get_offset_for_anchor(self, anchor, anchor_offset):
-        v_anchor, h_anchor = anchor.split("-")
+        v_anchor, _, h_anchor = anchor.partition("-")
 
         if h_anchor == "left":
             dx = -self.left + anchor_offset
@@ -1472,8 +1476,7 @@ def apply_block_layout(geometry, text_block):
     # The current line holds the text items for the line being processed.
     current_line = []
 
-    lines = []  # list of lists of TextItems
-    rects = []  # List of Rects
+    lines_rects = []  # List[ Tuple[ List[TextItem], Rect] ]
 
     def make_new_line(n_new_lines=1, n_new_paragraphs=0):
         nonlocal current_line, current_rect
@@ -1493,8 +1496,7 @@ def apply_block_layout(geometry, text_block):
             offset[0] += skip
         # Add the line
         if current_line:
-            lines.append(current_line)
-            rects.append(current_rect)
+            lines_rects.append((current_line, current_rect))
             current_line = []
             current_rect = Rect()
 
@@ -1571,7 +1573,7 @@ def apply_block_layout(geometry, text_block):
 
     # Calculate block rect. The top is positive, the bottom is negative (descender).
     block_rect = Rect()
-    for line, rect in zip(lines, rects):
+    for line, rect in lines_rects:
         # For rtl, align each line so left is at the origin
         if word_direction == "rtl":
             shift = -rect.left
@@ -1587,18 +1589,19 @@ def apply_block_layout(geometry, text_block):
             block_rect.top = max(block_rect.top, rect.top)
             block_rect.bottom = min(block_rect.bottom, rect.bottom)
 
+    first_rect, last_rect = lines_rects[0][1], lines_rects[-1][1]
     if line_direction == "ttb":
-        block_rect.top = rects[0].top
-        block_rect.bottom = offset[1] + line_height + rects[-1].bottom
+        block_rect.top = first_rect.top
+        block_rect.bottom = offset[1] + line_height + last_rect.bottom
     elif line_direction == "btt":
-        block_rect.top = offset[1] - line_height + rects[-1].top
-        block_rect.bottom = rects[0].bottom
+        block_rect.top = offset[1] - line_height + last_rect.top
+        block_rect.bottom = first_rect.bottom
     elif line_direction == "rtl":
-        block_rect.left = offset[0] + line_height + rects[-1].left
-        block_rect.right = rects[0].right
+        block_rect.left = offset[0] + line_height + last_rect.left
+        block_rect.right = first_rect.right
     elif line_direction == "ltr":
-        block_rect.left = rects[0].left
-        block_rect.right = offset[0] - line_height + rects[-1].right
+        block_rect.left = first_rect.left
+        block_rect.right = offset[0] - line_height + last_rect.right
 
     if text_align == "justify" or text_align_last == "justify":
         if max_width > 0:
@@ -1625,9 +1628,9 @@ def apply_block_layout(geometry, text_block):
 
     # Align the text, i.e. shift individual lines so they fit inside the block rect according to the current alignment
 
-    num_lines = len(lines)
+    num_lines = len(lines_rects)
     align = text_align
-    for i, (line, rect) in enumerate(zip(lines, rects)):
+    for i, (line, rect) in enumerate(lines_rects):
         if i == num_lines - 1:
             align = text_align_last
 
@@ -1664,20 +1667,21 @@ def apply_block_layout(geometry, text_block):
 
     # Update block's rect. Used by the final layout and to calculate bounding boxes.
     text_block._rect = block_rect
-    text_block._nlines = len(rects)
+    text_block._nlines = num_lines
 
 
 def apply_high_level_layout(geometry):
     text_blocks = geometry._text_blocks
 
     if not text_blocks:
-        geometry._aabb = np.zeros((2, 3), np.float32)
+        geometry._aabb = np.zeros((2, 3), "f4")
         return
 
+    font_size = geometry._font_size
     anchor = geometry._anchor
     anchor_offset = geometry._anchor_offset
-    line_height = geometry._line_height * geometry._font_size  # like CSS
-    par_spacing = geometry._paragraph_spacing * geometry._font_size
+    line_height = geometry._line_height * font_size  # like CSS
+    par_spacing = geometry._paragraph_spacing * font_size
 
     # Get line direction.
     line_direction = geometry._direction.partition("-")[2] or "ttb"
@@ -1685,7 +1689,7 @@ def apply_high_level_layout(geometry):
 
     # Calculate offsets to put the blocks beneath each-other, as well as the full rect.
     # Note that the distance between anchor points is independent on the anchor-mode.
-    offsets = np.zeros((len(text_blocks),), np.float32)
+    offsets = np.zeros((len(text_blocks),), "f4")
     offset = 0
     total_rect = Rect()
     if line_direction == "ttb":
@@ -1736,12 +1740,16 @@ def apply_high_level_layout(geometry):
     total_rect.shift(anchor_offset_x, anchor_offset_y)
 
     # Update positions
+    positions = geometry.positions
     if line_direction_is_vertical:
-        geometry.positions.data[: len(offsets), 0] = anchor_offset_x
-        geometry.positions.data[: len(offsets), 1] = offsets + anchor_offset_y
+        positions.data[: len(offsets), 0] = anchor_offset_x
+        positions.data[: len(offsets), 1] = offsets + anchor_offset_y
     else:
-        geometry.positions.data[: len(offsets), 0] = offsets + anchor_offset_x
-        geometry.positions.data[: len(offsets), 1] = anchor_offset_y
-    geometry.positions.update_range(0, len(offsets))
+        positions.data[: len(offsets), 0] = offsets + anchor_offset_x
+        positions.data[: len(offsets), 1] = anchor_offset_y
+
+    # Update full positions buffer to avoid overhead of chunking logic. Measurably faster.
+    positions.update_full()
+    # positions.update_range(0, len(offsets))
 
     return total_rect
diff --git a/pygfx/utils/text/_fontmanager.py b/pygfx/utils/text/_fontmanager.py
index 72b86be..6308744 100644
--- a/pygfx/utils/text/_fontmanager.py
+++ b/pygfx/utils/text/_fontmanager.py
@@ -270,6 +270,9 @@ class FontManager:
                 last_i = i = 0
                 for i in range(1, len(text)):
                     codepoint = ord(text[i])
+                    # Quick continue for default font
+                    if len(fonts) == 1 and fonts[0].has_codepoint(codepoint):
+                        continue
                     new_fonts = [ff for ff in fonts if ff.has_codepoint(codepoint)]
                     if new_fonts:
                         # Our selection of fonts is still nonzero
@@ -287,7 +290,10 @@ class FontManager:
                         text_pieces2.append((text[last_i:i], last_font))
                         last_i = i
                 last_font = fonts[0] if fonts else fallback_font
-                text_pieces2.append((text[last_i : i + 1], last_font))
+                if last_i == 0:
+                    text_pieces2.append((text, last_font))
+                else:
+                    text_pieces2.append((text[last_i : i + 1], last_font))
 
         # Did we encounter characters that we cannot render?
         failed_texts = [text for text, font in text_pieces2 if font is fallback_font]
diff --git a/pygfx/utils/text/_sdf.py b/pygfx/utils/text/_sdf.py
index beb2b5d..1d8afd6 100644
--- a/pygfx/utils/text/_sdf.py
+++ b/pygfx/utils/text/_sdf.py
@@ -40,7 +40,7 @@ def generate_glyph(glyph_indices, font_filename):
     # we keep the face alive in the cache.
     face = CACHE_FT[font_filename]
 
-    atlas_indices = np.zeros((len(glyph_indices),), np.uint32)
+    atlas_indices = np.empty((len(glyph_indices),), "u4")
     for i in range(len(glyph_indices)):
         glyph_index = int(glyph_indices[i])
         glyph_hash = (font_index, glyph_index)
diff --git a/pygfx/utils/text/_shaper.py b/pygfx/utils/text/_shaper.py
index c6ae55d..980ab2c 100644
--- a/pygfx/utils/text/_shaper.py
+++ b/pygfx/utils/text/_shaper.py
@@ -47,6 +47,8 @@ class TemporalCache:
         self._cache = {}
         self._lifetimes = {}
         self._getter = getter
+        self._last_key = None
+        self._last_val = None
 
     def __getitem__(self, key):
         """Gets the object corresponding to the given key.
@@ -54,14 +56,26 @@ class TemporalCache:
         Will reset the item's lifetime.
         Getting triggers the lifetimes of all items to be checked.
         """
+        # Make subsequent calls *really* fast
+        if key == self._last_key:
+            self._lifetimes[key] = time.time()
+            return self._last_val
+
+        # Get result
         try:
             res = self._cache[key]
         except KeyError:
             res = self._getter(key)
             self._cache[key] = res
 
+        self._last_key = key
+        self._last_val = res
+
+        # Update cache
         self._lifetimes[key] = time.time()
-        self.check_lifetimes()
+        if len(self._cache) > self._minimum_items:
+            self.check_lifetimes()
+
         return res
 
     def __contains__(self, key):
@@ -154,7 +168,7 @@ def shape_text_hb(text, font_filename, direction=None):
         is_horizontal = direction in ("ltr", "rtl")
 
     # Load font, maybe from the cache
-    blob, face, font = CACHE_HB[font_filename]
+    _blob, _face, font = CACHE_HB[font_filename]
 
     # Shape!
     uharfbuzz.shape(font, buf)
@@ -165,8 +179,8 @@ def shape_text_hb(text, font_filename, direction=None):
 
     # Get glyph indices (these can be different from the text's Unicode
     # code points) and convert advances to positions.
-    glyph_indices = np.zeros((n_glyphs,), np.uint32)
-    positions = np.zeros((n_glyphs, 2), np.float32)
+    glyph_indices = np.empty((n_glyphs,), "u4")
+    positions = np.empty((n_glyphs, 2), "f4")
     pen_x = pen_y = 0
     for i in range(n_glyphs):
         glyph_indices[i] = glyph_infos[i].codepoint
@@ -219,7 +233,7 @@ def shape_text_ft(text, font_filename, direction=None):
     advances = [(x / 65536 if x > 65536 * 10 else x) for x in advances]
 
     # Convert advances to positions
-    positions = np.zeros((n_glyphs, 2), np.float32)
+    positions = np.empty((n_glyphs, 2), "f4")
     pen_x = 0
     prev = " "
     for i in range(n_glyphs):
diff --git a/pygfx/utils/text/_tokenizers.py b/pygfx/utils/text/_tokenizers.py
index 396d5c3..7c45623 100644
--- a/pygfx/utils/text/_tokenizers.py
+++ b/pygfx/utils/text/_tokenizers.py
@@ -31,23 +31,16 @@ def _tokenze(text, groups, prog):
     pos = 0
     while True:
         match = prog.search(text, pos)
-
-        if not match:
-            other = text[pos:]
-            if other:
-                yield "other", other
+        if match is None:
             break
 
-        other = text[pos : match.start()]
-        if other:
-            yield "other", other
-
-        s = match.group()
-        for group_index, group_name in enumerate(groups, 1):
-            if match.group(group_index):
-                yield group_name, s
-                break
-        else:
-            yield "other", s
+        start = match.start()
+        if start > pos:
+            yield "other", text[pos : match.start()]
 
+        yield groups[match.lastindex - 1], match.group()
         pos = match.end()
+
+    other = text[pos:]
+    if other:
+        yield "other", other


Test patch:
diff --git a/tests/geometries/test_text_geometry.py b/tests/geometries/test_text_geometry.py
index 5561700..adb3b5e 100644
--- a/tests/geometries/test_text_geometry.py
+++ b/tests/geometries/test_text_geometry.py
@@ -116,20 +116,53 @@ def test_text_geometry_blocks():
 
 
 def test_text_geometry_blocks_buffer():
-    geo = MultiTextGeometry()
+    class MyTextGeometry(MultiTextGeometry):
+        allocation_count = 0
+
+        def _allocate_block_buffers(self, n):
+            super()._allocate_block_buffers(n)
+            self.allocation_count += 1
+
+    geo = MyTextGeometry()
+
     assert geo.positions.nitems == 8
+    assert geo.allocation_count == 0
+
+    # Note that the minimum is 8
+
+    geo.set_text_block_count(1)
+    assert geo.positions.nitems == 8
+    assert geo.allocation_count == 0
+
+    geo.set_text_block_count(1)
+    assert geo.positions.nitems == 8
+    assert geo.allocation_count == 0
+
+    geo.set_text_block_count(2)
+    assert geo.positions.nitems == 8
+    assert geo.allocation_count == 0
 
     geo.set_text_block_count(8)
     assert geo.positions.nitems == 8
+    assert geo.allocation_count == 0
+
+    # Now we bump
+
+    geo.set_text_block_count(9)
+    assert geo.positions.nitems == 16
+    assert geo.allocation_count == 1
 
     geo.set_text_block_count(9)
     assert geo.positions.nitems == 16
+    assert geo.allocation_count == 1
 
     geo.set_text_block_count(16)
     assert geo.positions.nitems == 16
+    assert geo.allocation_count == 1
 
     geo.set_text_block_count(17)
     assert geo.positions.nitems == 32
+    assert geo.allocation_count == 2
 
     geo.set_text_block_count(33)
     assert geo.positions.nitems == 64
diff --git a/tests/utils/test_text_shaping.py b/tests/utils/test_text_shaping.py
index 5fdff60..ed2ea11 100644
--- a/tests/utils/test_text_shaping.py
+++ b/tests/utils/test_text_shaping.py
@@ -25,6 +25,7 @@ def test_cache():
     assert c["bar"] == hash("bar")
     assert "foo" in c
     assert "bar" in c
+    assert c["spam"]  # make "bar" not the last lookup
 
     # Wait again, now get a previously nonexistent item to trigger removing foo
     time.sleep(0.11)