Repository: pygfx/pygfx
Pull request #1010 (2025-03-03 12:34:15)

Use single buffer to store per-glyph data
This PR changes the text geometry to use a single buffer for per-glyph data, instead of 4 separate buffers. This makes the code cleaner, and easier to add more data if we want later. It's also faster on the CPU side, because only one call to `.update_indices()` instead of 4. And possibly faster on the GPU (could not measure that effect though).



hmaarrfk: We have definitely been feeling the effects of multiple calls to send data to the GPU!!!! Thanks for caring about this kind of optimization while everything is fresh in the mind!

Patch:
diff --git a/pygfx/geometries/_text.py b/pygfx/geometries/_text.py
index eb6a4c9..aa8510d 100644
--- a/pygfx/geometries/_text.py
+++ b/pygfx/geometries/_text.py
@@ -27,7 +27,7 @@ different format (bold, italic, size).
 Layout is performed on each block, shifting the text items into position based
 on text_align, anchor and direction. This positoning is done by offsetting the
 item's array of glyph positions. The offset is applied when the item's positions
-are copied into the geometry's big glyph_positions buffer.
+are copied into the geometry's big glyph_data buffer.
 
 The TextGeometry also performs a high level layout by positioning the blocks.
 The MultiTextGeometry does not do this, as the user is responsible for
@@ -51,6 +51,38 @@ ANCHOR_ALIASES["center"] = ANCHOR_ALIASES["middle"] = "middle-center"
 # We cache the extents of small whitespace strings to improve performance
 WHITESPACE_EXTENTS = {}
 
+# This is the dtype for per-glyph data. In the shader we can only use 32bit datatypes (maybe f16 in a future version).
+# To safe memory, we combine the atlas_index and format mask into a single u32. Here they look like two uint16 but
+# in the shader they're interpreted as a single word, and decomposed.
+GLYPH_DTYPE = np.dtype(
+    [
+        ("pos", "f4", 2),
+        ("size", "f4"),
+        ("block_index", "u4"),
+        ("atlas_index", "u2"),
+        ("format", "u2"),  # bitmask encoding relative weight and more
+    ]
+)
+
+
+def encode_format(relative_weight, relative_slant):
+    """Encode format props into a u16"""
+    # Weigth: -250 .. 500, steps of 50  -> 4 bits
+    relative_weight = min(max(relative_weight, -250), 500)
+    weight_4 = int((relative_weight + 250) / 50 + 0.4999)
+    # weight = weight_4 * 50 - 250
+
+    # Slant: -1.75 .. 2, steps of 0.25 -> 4 bits
+    relative_slant = min(max(relative_slant, -1.75), 2)
+    slant_4 = int((relative_slant + 1.75) * 4 + 0.4999)
+    # slant = slant_4 / 4.0 - 1.75;
+
+    # There'res 8 bits left for possible future use
+
+    packed = (weight_4 << 12) + (slant_4 << 8)
+
+    return packed
+
 
 class TextEngine:
     """A small abstraction that allows subclasses of TextGeometry to use a different text engine."""
@@ -166,21 +198,14 @@ class TextGeometry(Geometry):
         if len(text_and_markdown) > 1:
             raise TypeError("Either text or markdown must be given, not both.")
 
-        # --- create per-item arrays/buffers
+        # --- create buffers
 
         # The position of each text block
         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,), "u4"))
-        # Index into the block list above (i.e. the block.index)
-        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), "f4"))
-        self.glyph_sizes = Buffer(np.zeros((16,), "f4"))
+        # The per-glyph data, stored with a structured dtype
+        self.glyph_data = Buffer(np.zeros(16, GLYPH_DTYPE))
 
         # --- init variables to help manage the glyph arrays
 
@@ -548,24 +573,30 @@ class TextGeometry(Geometry):
 
         # Update blocks
         need_high_level_layout = False
+        need_glyph_data_sync = False
         for index in dirty_blocks:
             try:
                 block = self._text_blocks[index]
             except IndexError:
                 continue  # block was removed after being marked dirty
-            did_block_layout = block._update(self)
+            did_block_layout, did_new_glyph_data = block._update(self)
             need_high_level_layout |= did_block_layout
+            need_glyph_data_sync |= did_new_glyph_data
 
         # Reset
         dirty_blocks.clear()
 
         # Update drawing range. Note that the "gaps" are still rendered.
-        self.draw_range = 0, self._glyph_indices_top
+        self.glyph_data.draw_range = 0, self._glyph_indices_top
 
         # Higher-level layout
         if need_high_level_layout:
             self._layout_blocks()
 
+        # Updating in full turns out to be more efficient than doing all the calls to update_indices
+        if need_glyph_data_sync:
+            self.glyph_data.update_full()
+
     def _layout_blocks(self):
         total_rect = apply_high_level_layout(self)
         self._aabb = np.array(
@@ -593,14 +624,19 @@ class TextGeometry(Geometry):
             new_size = max(8, new_size)
             self._allocate_block_buffers(new_size)
 
-        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)
+        if n == len(self._text_blocks):
+            pass
+        elif len(self._text_blocks) < n:
+            # Add blocks
             while len(self._text_blocks) < n:
                 block = TextBlock(len(self._text_blocks), self._dirty_blocks)
                 self._text_blocks.append(block)
+        else:
+            # Remove blocks
+            self.glyph_data.update_full()  # cleared blocks, means cleared glyph indices
+            while len(self._text_blocks) > n:
+                block = self._text_blocks.pop(-1)
+                block._clear(self)
 
     def _allocate_block_buffers(self, n):
         """Allocate new buffers for text blocks with the given size."""
@@ -612,7 +648,7 @@ class TextGeometry(Geometry):
     # --- glyph array management
 
     def _glyphs_allocate(self, n):
-        max_glyph_slots = self.glyph_positions.nitems
+        max_glyph_slots = self.glyph_data.nitems
 
         # Need larger buffer?
         n_free = max_glyph_slots - self._glyph_count
@@ -648,12 +684,8 @@ class TextGeometry(Geometry):
         # These glyphs will still end up in the vertex shader,
         # but it will discard early by producing degeneate triangles.
         # Clear data, for sanity
-        self.glyph_block_indices.data[indices] = 0
-        self.glyph_atlas_indices.data[indices] = 0
-        self.glyph_sizes.data[indices] = 0.0
-        self.glyph_positions.data[indices] = 0.0
-        # Only update the sizes, shader uses this to check empty slots
-        self.glyph_sizes.update_indices(indices)
+        self.glyph_data.data[indices] = 0
+        # self.glyph_data.update_indices(indices) -> update_full is called from _on_update_object when needed
         # Deallocate
         self._glyph_count -= len(indices)
         # Small optimization to avoid gaps
@@ -663,7 +695,7 @@ class TextGeometry(Geometry):
             self._glyph_indices_gaps.update(indices)
         # TODO: Reduce buffer sizes from the Text object, re-packing all items
         # # Maybe reduce buffer size
-        # max_glyph_slots = self.glyph_positions.nitems
+        # max_glyph_slots = self.glyph_data.nitems
         # if self._glyph_count < 0.25 * max_glyph_slots:
         #     self._glyphs_create_new_buffers()
 
@@ -674,25 +706,13 @@ class TextGeometry(Geometry):
         need_size = self._glyph_indices_top + extra_needed
         new_size = 2 ** int(np.ceil(np.log2(need_size)))
         new_size = max(16, new_size)
-
         # Prepare new arrays
-        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")
-
+        glyph_data = np.zeros(new_size, GLYPH_DTYPE)
         # Copy data over
         n = self._glyph_indices_top
-        glyph_block_indices[:n] = self.glyph_block_indices.data[:n]
-        glyph_atlas_indices[:n] = self.glyph_atlas_indices.data[:n]
-        glyph_positions[:n] = self.glyph_positions.data[:n]
-        glyph_sizes[:n] = self.glyph_sizes.data[:n]
-
+        glyph_data[:n] = self.glyph_data.data[:n]
         # Store
-        self.glyph_block_indices = Buffer(glyph_block_indices)
-        self.glyph_atlas_indices = Buffer(glyph_atlas_indices)
-        self.glyph_positions = Buffer(glyph_positions)
-        self.glyph_sizes = Buffer(glyph_sizes)
+        self.glyph_data = Buffer(glyph_data)
 
 
 class MultiTextGeometry(TextGeometry):
@@ -852,18 +872,20 @@ class TextBlock:
         # self._dirty_blocks.discard(self._index)  # no, geometry calls clear
         need_render_glyphs = self._need_render_glyphs
         need_layout = self._need_layout
+        need_glyph_data_upload = False
         self._need_render_glyphs = False
         self._need_layout = False
 
         # De-allocate old item objects
         if self._old_text_items:
+            need_glyph_data_upload = True
             for item in self._old_text_items:
                 item.clear(geometry)
             self._old_text_items = []
 
         # Quick exit
         if not (need_render_glyphs or need_layout):
-            return False
+            return False, False
 
         # Update in-use item objects
         for item in self._text_items:
@@ -877,9 +899,10 @@ class TextBlock:
         # Item updates, and layout, may require syncing glyph data
         for item in self._text_items:
             if item.need_sync_with_geometry:
+                need_glyph_data_upload = True
                 item.sync_with_geometry(geometry, self._index)
 
-        return need_layout  # i.e. did_layout
+        return need_layout, need_glyph_data_upload  # i.e. did_layout
 
     def _clear(self, geometry):
         if self._old_text_items:
@@ -1040,14 +1063,14 @@ class TextBlock:
                 modifier = kind.partition(":")[-1]
                 if modifier == "+b":
                     bold_level += 1
-                    format["weight"] = 300
+                    format["weight"] = 300  # weight can be -250 .. 500
                 elif modifier == "-b":
                     bold_level -= 1
                     if not bold_level:
                         format.pop("weight", None)
                 elif modifier == "+i":
                     italic_level += 1
-                    format["slant"] = True
+                    format["slant"] = 1  # slant can be -1.75 .. 2.00
                 elif modifier == "-i":
                     italic_level -= 1
                     if not italic_level:
@@ -1092,6 +1115,7 @@ class TextItem:
         "descender",
         "direction",
         "extent",
+        "formats",
         "glyph_count",
         "glyph_indices",
         "layout_offset",
@@ -1123,6 +1147,7 @@ class TextItem:
         self.atlas_indices = None
         self.positions = None
         self.sizes = None
+        self.formats = None
         self.glyph_count = 0
 
         # The indices for slots in the arrays at the geometry. This value is managed by the geometry.
@@ -1175,6 +1200,7 @@ class TextItem:
             self.atlas_indices = None
             self.positions = None
             self.sizes = None
+            self.formats = None
             self.glyph_count = 0
             if self.ws_before:
                 font = textengine.select_font(" ", font_props)[0][1]
@@ -1187,6 +1213,7 @@ class TextItem:
         atlas_indices_list = []
         positions_list = []
         sizes_list = []
+        formats_list = []
 
         # Init meta data
         extent = ascender = descender = 0
@@ -1195,21 +1222,22 @@ class TextItem:
         # Text rendering steps: font selection, shaping, glyph generation
         last_reverse_index = 0
         for format, text2 in self.pieces:
+            rsize = format.get("size", 1.0)
+            format_mask = encode_format(format.get("weight", 0), format.get("slant", 0))
             for text, font in textengine.select_font(text2, font_props):
-                rsize = format.get("size", 1.0)
-                weight, slant = format.get("weight", 0), format.get("slant", False)
                 unicode_indices, positions, meta = textengine.shape_text(
                     text, font.filename, direction
                 )
                 atlas_indices = textengine.generate_glyph(
                     unicode_indices, font.filename
                 )
-                encode_font_props_in_atlas_indices(atlas_indices, weight, slant)
+                n = atlas_indices.shape[0]
                 if rsize != 1.0:
                     positions *= rsize
                 if extent:
                     positions[:, 0] += extent  # put pieces next to each-other
-                sizes = np.full((positions.shape[0],), rsize, "f4")
+                sizes = np.full(n, rsize, "f4")
+                formats = np.full(n, format_mask, "u2")
                 extent = extent + meta["extent"] * rsize
                 ascender = max(ascender, meta["ascender"] * rsize)
                 descender = min(descender, meta["descender"] * rsize)  # is neg
@@ -1221,10 +1249,12 @@ class TextItem:
                     atlas_indices_list.insert(last_reverse_index, atlas_indices)
                     positions_list.insert(last_reverse_index, positions)
                     sizes_list.insert(last_reverse_index, sizes)
+                    formats_list.insert(last_reverse_index, formats)
                 else:
                     atlas_indices_list.append(atlas_indices)
                     positions_list.append(positions)
                     sizes_list.append(sizes)
+                    formats_list.append(formats)
                     last_reverse_index = len(atlas_indices_list)
 
                 # Calculate margin_before based on the font and direction of the first piece
@@ -1245,16 +1275,19 @@ class TextItem:
             self.atlas_indices = None
             self.positions = None
             self.sizes = None
+            self.formats = None
             self.glyph_count = 0
         elif len(atlas_indices_list) == 1:
             self.atlas_indices = atlas_indices_list[0]
             self.positions = positions_list[0]
             self.sizes = sizes_list[0]
+            self.formats = formats_list[0]
             self.glyph_count = len(self.positions)
         else:
             self.atlas_indices = np.concatenate(atlas_indices_list, axis=0)
             self.positions = np.concatenate(positions_list, axis=0)
             self.sizes = np.concatenate(sizes_list, axis=0)
+            self.formats = np.concatenate(formats_list, axis=0)
             self.glyph_count = len(self.positions)
 
     def sync_with_geometry(self, geometry, block_index):
@@ -1297,22 +1330,18 @@ class TextItem:
         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
-        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
-        glyph_block_indices.update_indices(indices)
-        glyph_atlas_indices.update_indices(indices)
-        glyph_positions.update_indices(indices)
-        glyph_sizes.update_indices(indices)
+        glyph_data = geometry.glyph_data
+
+        # Get subset and mark indices for upload
+        glyph_data_array = glyph_data.data
+        # glyph_data.update_indices(indices) -> doing a full upload is faster (done in _on_update_object)
+
+        # Write data
+        glyph_data_array["pos"][indices] = positions
+        glyph_data_array["size"][indices] = sizes
+        glyph_data_array["atlas_index"][indices] = self.atlas_indices
+        glyph_data_array["block_index"][indices] = block_index
+        glyph_data_array["format"][indices] = self.formats
 
     def clear(self, geometry):
         if len(self.glyph_indices):
@@ -1320,22 +1349,6 @@ class TextItem:
             self.glyph_indices = np.zeros((0,), "u4")
 
 
-def encode_font_props_in_atlas_indices(atlas_indices, weight, slant):
-    # We could put font properties in their own buffer(s), but to
-    # safe memory, we encode them in the top bits of the atlas
-    # indices. This seems like a good place, because these top bits
-    # won't be used (2**24 is more than enough glyphs), and the
-    # glyph index is a rather "opaque" value to the user anyway.
-    # You can think of the new glyph index as the index to the glyph
-    # in the atlas, plus props to tweak its appearance.
-    if slant:
-        atlas_indices += 0x08000000
-    # weight_offset = font_props.weight - font.weight
-    weight_offset = weight
-    weight_0_15 = int((max(-250, weight_offset) + 250) / 50 + 0.4999)
-    atlas_indices += max(0, min(15, weight_0_15)) << 28
-
-
 class Rect:
     __slots__ = ["bottom", "left", "right", "top"]
 
diff --git a/pygfx/renderers/wgpu/shaders/textshader.py b/pygfx/renderers/wgpu/shaders/textshader.py
index d57bea3..06f5722 100644
--- a/pygfx/renderers/wgpu/shaders/textshader.py
+++ b/pygfx/renderers/wgpu/shaders/textshader.py
@@ -38,14 +38,6 @@ class TextShader(BaseShader):
             Binding("u_wobject", "buffer/uniform", wobject.uniform_buffer),
             Binding("u_material", "buffer/uniform", material.uniform_buffer),
             Binding("s_positions", sbuffer, geometry.positions, "VERTEX"),
-            Binding(
-                "s_glyph_block_indices", sbuffer, geometry.glyph_block_indices, "VERTEX"
-            ),
-            Binding(
-                "s_glyph_atlas_indices", sbuffer, geometry.glyph_atlas_indices, "VERTEX"
-            ),
-            Binding("s_glyph_positions", sbuffer, geometry.glyph_positions, "VERTEX"),
-            Binding("s_glyph_sizes", sbuffer, geometry.glyph_sizes, "VERTEX"),
         ]
 
         tex = shared.glyph_atlas_texture
@@ -60,8 +52,9 @@ class TextShader(BaseShader):
 
         bindings1 = {}
         bindings1[0] = Binding(
-            "s_glyph_infos", sbuffer, shared.glyph_atlas_info_buffer, "VERTEX"
+            "s_glyph_info", sbuffer, shared.glyph_atlas_info_buffer, "VERTEX"
         )
+        bindings1[1] = Binding("s_glyph_data", sbuffer, geometry.glyph_data, "VERTEX")
 
         return {
             0: bindings,
@@ -76,7 +69,7 @@ class TextShader(BaseShader):
 
     def get_render_info(self, wobject, shared):
         material = wobject.material
-        n = wobject.geometry.glyph_positions.draw_range[1] * 6
+        n = wobject.geometry.glyph_data.draw_range[1] * 6
 
         render_mask = 0
         if wobject.render_mask:
diff --git a/pygfx/renderers/wgpu/wgsl/text.wgsl b/pygfx/renderers/wgpu/wgsl/text.wgsl
index 9ba7517..901a646 100644
--- a/pygfx/renderers/wgpu/wgsl/text.wgsl
+++ b/pygfx/renderers/wgpu/wgsl/text.wgsl
@@ -17,8 +17,21 @@ struct GlyphInfo {
     offset: vec2<f32>,  // positional offset of the glyph
 };
 
+// The per-glyph struct. Should match text's GLYPH_DTYPE
+struct GlyphData {
+    x: f32,
+    y: f32,
+    s: f32,  // f16 is plenty precise, but is unknown type, I think our current wgsl is not recent enoug    h to support it
+    block_index: u32,
+    atlas_index_and_format: u32,
+}
+
+
 @group(1) @binding(0)
-var<storage,read> s_glyph_infos: array<GlyphInfo>;
+var<storage,read> s_glyph_info: array<GlyphInfo>;
+
+@group(1) @binding(1)
+var<storage,read> s_glyph_data: array<GlyphData>;
 
 
 @vertex
@@ -30,25 +43,27 @@ fn vs_main(in: VertexInput) -> Varyings {
     let index = raw_index / 6;
     let sub_index = raw_index % 6;
 
-    // Load glyph info
-    let block_index = i32(load_s_glyph_block_indices(index));
-    let glyph_index_raw = u32(load_s_glyph_atlas_indices(index));
-    let font_size = load_s_glyph_sizes(index);
-    let glyph_pos = load_s_glyph_positions(index);
+    // Load glyph data
+    let glyph_data = s_glyph_data[index];
+    let block_index  = i32(glyph_data.block_index);
+    let atlas_index = i32(glyph_data.atlas_index_and_format & 0xFFFFu);
+    let format_bitmask = (glyph_data.atlas_index_and_format & 0xFFFF0000u) >> 16u;
+    let glyph_pos = vec2<f32>(glyph_data.x, glyph_data.y);
+    let font_size = f32(glyph_data.s);
 
+    // Get position of the current block
     let block_pos: vec3<f32> = load_s_positions(block_index);
 
-
     // Extract actual glyph index and the encoded font props
-    let atlas_index = u32(glyph_index_raw & 0x00FFFFFFu);
-    let weight_0_15 = (glyph_index_raw & 0xF0000000u) >> 28u;  // highest 4 bits
-    let is_slanted = bool(glyph_index_raw & 0x08000000u);
-    //let reserved1 = bool(glyph_index_raw & 0x04000000u);
-    //let reserved2 = bool(glyph_index_raw & 0x02000000u);
-    //let reserved3 = bool(glyph_index_raw & 0x01000000u);
+    let weight_0_15 = (format_bitmask & 0xF000u) >> 12u;  // 4 bits encode -250 .. 500 in steps of 50
+    let weight_offset = f32(weight_0_15) * 50.0 - 250.0;
+    let slant_0_15 = (format_bitmask & 0x0F00u) >> 8u;  // 4 bits encode -1.75 .. 2 in steps of 0.25
+    let slant_offset = f32(slant_0_15) / 4.0 - 1.75;
+    //let reserved1 = (format_bitmask & 0x00F0u) >> 4u;
+    //let reserved2 = (format_bitmask & 0x000Fu);
 
     // Load meta-data of the glyph in the atlas
-    let glyph_info = s_glyph_infos[atlas_index];
+    let glyph_info = s_glyph_info[atlas_index];
     let bitmap_rect = vec4<i32>(glyph_info.origin, glyph_info.size );
 
     // Prep correction vectors
@@ -64,7 +79,7 @@ fn vs_main(in: VertexInput) -> Varyings {
     let corner = corners[sub_index];
 
     // Apply slanting, a.k.a. automated obliques, a.k.a. fake italics
-    let slant_factor = f32(is_slanted) * 0.23;  // empirically selected based on NotoSans-Italic
+    let slant_factor = slant_offset * 0.23;  // empirically selected based on NotoSans-Italic
     let slant = vec2<f32>(0.5 - corner.y, 0.0) * slant_factor;
 
     let pos_corner_factor = corner * vec2<f32>(1.0, -1.0);
@@ -148,7 +163,7 @@ fn vs_main(in: VertexInput) -> Varyings {
     varyings.world_pos = vec3<f32>(world_pos.xyz / world_pos.w);
     varyings.atlas_pixel_scale = f32(atlas_pixel_scale);
     varyings.texcoord_in_pixels = vec2<f32>(texcoord_in_pixels);
-    varyings.weight_offset = f32(f32(weight_0_15) * 50.0 - 250.0);  // encodes -250..500 in steps of 50
+    varyings.weight_offset = f32(weight_offset);
 
     // Picking
     varyings.pick_idx = u32(index);


Test patch:
diff --git a/tests/geometries/test_text_geometry.py b/tests/geometries/test_text_geometry.py
index adb3b5e..29fad05 100644
--- a/tests/geometries/test_text_geometry.py
+++ b/tests/geometries/test_text_geometry.py
@@ -23,21 +23,21 @@ def test_text_geometry_text():
     assert len(geo._text_blocks) == 0
     assert geo._glyph_count == 0
     assert geo.positions.nitems == min_blocks
-    assert geo.glyph_atlas_indices.nitems == min_glyphs
+    assert geo.glyph_data.nitems == min_glyphs
 
     # Only a newline
     geo = TextGeometry("\n")  # also test that text is a positional arg
     assert len(geo._text_blocks) == 1
     assert geo._glyph_count == 0
     assert geo.positions.nitems == min_blocks
-    assert geo.glyph_atlas_indices.nitems == min_glyphs
+    assert geo.glyph_data.nitems == min_glyphs
 
     # Only a space
     geo = TextGeometry(" ")  # also test that text is a positional arg
     assert len(geo._text_blocks) == 1
     assert geo._glyph_count == 0
     assert geo.positions.nitems == min_blocks
-    assert geo.glyph_atlas_indices.nitems == min_glyphs
+    assert geo.glyph_data.nitems == min_glyphs
 
     # One char
     geo = TextGeometry(text="a")
@@ -45,7 +45,7 @@ def test_text_geometry_text():
     assert len(geo._text_blocks[0]._text_items) == 1
     assert geo._glyph_count == 1
     assert geo.positions.nitems == min_blocks
-    assert geo.glyph_atlas_indices.nitems == min_glyphs
+    assert geo.glyph_data.nitems == min_glyphs
 
     # Two words with 3 chars in total
     geo = TextGeometry(text="a bc")
@@ -53,31 +53,31 @@ def test_text_geometry_text():
     assert len(geo._text_blocks[0]._text_items) == 2
     assert geo._glyph_count == 3
     assert geo.positions.nitems == min_blocks
-    assert geo.glyph_atlas_indices.nitems == min_glyphs
+    assert geo.glyph_data.nitems == min_glyphs
 
     # Can set new text, buffers are recreated
     geo.set_text("foo\nbar")
     assert len(geo._text_blocks) == 2
     assert geo._glyph_count == 6
     assert geo.positions.nitems == min_blocks
-    assert geo.glyph_atlas_indices.nitems == min_glyphs
+    assert geo.glyph_data.nitems == min_glyphs
 
     # Mo text
     geo.set_text("foo bar spam eggs\naap noot mies")
     assert len(geo._text_blocks) == 2
     assert geo._glyph_count == 25
     assert geo.positions.nitems == min_blocks
-    assert geo.glyph_atlas_indices.nitems == 32
+    assert geo.glyph_data.nitems == 32
 
     # If setting smaller text, buffer size is oversized
     geo.set_text("x")
     assert len(geo._text_blocks) == 1
     assert geo._glyph_count == 1
     assert geo.positions.nitems == min_blocks
-    assert geo.glyph_atlas_indices.nitems == 32
+    assert geo.glyph_data.nitems == 32
 
     # Sizes are used to invalidate unused slots
-    assert np.all(geo.glyph_sizes.data[1:] == 0)
+    assert np.all(geo.glyph_data.data["size"][1:] == 0)
 
 
 def test_text_geometry_blocks():
@@ -225,10 +225,10 @@ def test_text_geometry_whitespace():
     t3 = TextGeometry(text=" abc def", anchor="baseline-left")
     t4 = TextGeometry(text="abc def ", anchor="baseline-left")
 
-    x1 = t1.glyph_positions.data[t1._glyph_count - 1, 0]
-    x2 = t2.glyph_positions.data[t2._glyph_count - 1, 0]
-    x3 = t3.glyph_positions.data[t3._glyph_count - 1, 0]
-    x4 = t4.glyph_positions.data[t4._glyph_count - 1, 0]
+    x1 = t1.glyph_data.data["pos"][t1._glyph_count - 1, 0]
+    x2 = t2.glyph_data.data["pos"][t2._glyph_count - 1, 0]
+    x3 = t3.glyph_data.data["pos"][t3._glyph_count - 1, 0]
+    x4 = t4.glyph_data.data["pos"][t4._glyph_count - 1, 0]
 
     assert x2 > x1  # Extra spaces in between words are preserved
     assert x3 > x1  # Extra space at the start is also preserved
@@ -241,10 +241,10 @@ def test_text_geometry_whitespace():
     t3 = TextGeometry(text=" abc def", anchor="baseline-right")
     t4 = TextGeometry(text="abc def ", anchor="baseline-right")
 
-    x1 = t1.glyph_positions.data[0, 0]
-    x2 = t2.glyph_positions.data[0, 0]
-    x3 = t3.glyph_positions.data[0, 0]
-    x4 = t4.glyph_positions.data[0, 0]
+    x1 = t1.glyph_data.data["pos"][0, 0]
+    x2 = t2.glyph_data.data["pos"][0, 0]
+    x3 = t3.glyph_data.data["pos"][0, 0]
+    x4 = t4.glyph_data.data["pos"][0, 0]
 
     x1 += t1.positions.data[0, 0]
     x2 += t2.positions.data[0, 0]
@@ -302,11 +302,11 @@ def test_text_geometry_anchor():
 
 def test_text_geometry_direction_ttb():
     t1 = TextGeometry("abc def", direction="ltr")
-    x1, y1 = t1.glyph_positions.data[:, 0], t1.glyph_positions.data[:, 1]
+    x1, y1 = t1.glyph_data.data["pos"][:, 0], t1.glyph_data.data["pos"][:, 1]
     assert (x1.max() - x1.min()) > (y1.max() - y1.min())
 
     t2 = TextGeometry("abc def", direction="ttb")
-    x2, y2 = t2.glyph_positions.data[:, 0], t2.glyph_positions.data[:, 1]
+    x2, y2 = t2.glyph_data.data["pos"][:, 0], t2.glyph_data.data["pos"][:, 1]
     assert (x2.max() - x2.min()) < (y2.max() - y2.min())
 
 
@@ -407,10 +407,10 @@ def test_geometry_text_align_1():
     text = [ref_text]
 
     def xpos(geo, block_i, glyph_i):
-        return geo.positions.data[block_i, 0] + geo.glyph_positions.data[glyph_i, 0]
+        return geo.positions.data[block_i, 0] + geo.glyph_data.data["pos"][glyph_i, 0]
 
     def ypos(geo, block_i, glyph_i):
-        return geo.positions.data[block_i, 1] + geo.glyph_positions.data[glyph_i, 1]
+        return geo.positions.data[block_i, 1] + geo.glyph_data.data["pos"][glyph_i, 1]
 
     # Setting text to list of text, to make sure to use a single block.
     geometry_left = TextGeometry(
@@ -490,10 +490,10 @@ def test_geometry_text_align_2():
     text = ref_text
 
     def xpos(geo, block_i, glyph_i):
-        return geo.positions.data[block_i, 0] + geo.glyph_positions.data[glyph_i, 0]
+        return geo.positions.data[block_i, 0] + geo.glyph_data.data["pos"][glyph_i, 0]
 
     def ypos(geo, block_i, glyph_i):
-        return geo.positions.data[block_i, 1] + geo.glyph_positions.data[glyph_i, 1]
+        return geo.positions.data[block_i, 1] + geo.glyph_data.data["pos"][glyph_i, 1]
 
     # Setting text to list of text, to make sure to use a single block.
     geometry_left = TextGeometry(
@@ -583,7 +583,7 @@ def test_geometry_text_with_new_lines():
     )
 
     def ypos(geo, block_i, glyph_i):
-        return geo.positions.data[block_i, 1] + geo.glyph_positions.data[glyph_i, 1]
+        return geo.positions.data[block_i, 1] + geo.glyph_data.data["pos"][glyph_i, 1]
 
     assert ypos(geo1, 0, 0) == ypos(geo2, 0, 0)
     assert ypos(geo1, 0, 9) > ypos(geo2, 0, 9)
@@ -607,14 +607,14 @@ def test_alignment_with_spaces():
     i1 = 0
     i2 = 10
     i3 = 20
-    assert geo.glyph_positions.data[i1, 0] < geo.glyph_positions.data[i2, 0]
-    assert geo.glyph_positions.data[i1, 0] == geo.glyph_positions.data[i3, 0]
+    assert geo.glyph_data.data["pos"][i1, 0] < geo.glyph_data.data["pos"][i2, 0]
+    assert geo.glyph_data.data["pos"][i1, 0] == geo.glyph_data.data["pos"][i3, 0]
 
     geo.text_align = "right"
     geo._on_update_object()
 
-    assert geo.glyph_positions.data[i1, 0] > geo.glyph_positions.data[i3, 0]
-    assert geo.glyph_positions.data[i1, 0] == geo.glyph_positions.data[i2, 0]
+    assert geo.glyph_data.data["pos"][i1, 0] > geo.glyph_data.data["pos"][i3, 0]
+    assert geo.glyph_data.data["pos"][i1, 0] == geo.glyph_data.data["pos"][i2, 0]
 
 
 def test_geometry_text_align_last():
@@ -644,21 +644,21 @@ def test_geometry_text_align_last():
     )
     i = geometry_default._glyph_count - 1
     assert (
-        geometry_default.glyph_positions.data[i, 0]
-        == geometry_left.glyph_positions.data[i, 0]
+        geometry_default.glyph_data.data["pos"][i, 0]
+        == geometry_left.glyph_data.data["pos"][i, 0]
     )
     assert (
-        geometry_left.glyph_positions.data[i, 0]
-        < geometry_center.glyph_positions.data[i, 0]
+        geometry_left.glyph_data.data["pos"][i, 0]
+        < geometry_center.glyph_data.data["pos"][i, 0]
     )
     assert (
-        geometry_center.glyph_positions.data[i, 0]
-        < geometry_right.glyph_positions.data[i, 0]
+        geometry_center.glyph_data.data["pos"][i, 0]
+        < geometry_right.glyph_data.data["pos"][i, 0]
     )
     # unless you specify justify-all
     assert (
-        geometry_right.glyph_positions.data[i, 0]
-        == geometry_justify.glyph_positions.data[i, 0]
+        geometry_right.glyph_data.data["pos"][i, 0]
+        == geometry_justify.glyph_data.data["pos"][i, 0]
     )
 
 
@@ -668,12 +668,12 @@ def test_text_geometry_leading_spaces():
     with_3whitespace = TextGeometry([" \n   hello"], anchor="top-left")
     # The spaces should not be stripped at the front
     assert (
-        basic_text.glyph_positions.data[0, 0]
-        < with_1whitespace.glyph_positions.data[0, 0]
+        basic_text.glyph_data.data["pos"][0, 0]
+        < with_1whitespace.glyph_data.data["pos"][0, 0]
     )
     assert (
-        with_1whitespace.glyph_positions.data[0, 0]
-        < with_3whitespace.glyph_positions.data[0, 0]
+        with_1whitespace.glyph_data.data["pos"][0, 0]
+        < with_3whitespace.glyph_data.data["pos"][0, 0]
     )