Repository: pygfx/pygfx
Pull request #560 (2023-07-05 15:22:14)

Tweaks to better support dynamic meshes
Relies on (and currently includes) #561.

* [x] Support meshes with zero faces.
* [x] Bounding box resets when buffer is reset (by making the `rev` counter global). 
* [x] Introduce new `.view` prop so only a (contiguous) part of a mesh is shown.
* [x] Is `.view` a good name? I think ppl may confuse it with a numpy view or a texture view. Maybe `.range` or something?
* [x] Double-check that it also works for mesh slices.
* [x] Support the new prop for other objects, like line and points. (Could be a separate pr.) 
* [x] Replace `vertex_byte_range` with the new prop. 

Korijn: > * [ ]  Is `.view` a good name? I think ppl may confuse it with a numpy view or a texture view. Maybe `.range` or something?

It's `drawRange` in ThreeJS: https://threejs.org/docs/index.html?q=buff#api/en/core/BufferGeometry.drawRange
almarklein: Also for lines and points!

<img width="649" alt="image" src="https://github.com/pygfx/pygfx/assets/3015475/df795edf-9c70-45be-9bae-8e841da096d5">


Patch:
diff --git a/examples/feature_demo/mesh_dynamic.py b/examples/feature_demo/mesh_dynamic.py
new file mode 100644
index 0000000..7f58aea
--- /dev/null
+++ b/examples/feature_demo/mesh_dynamic.py
@@ -0,0 +1,31 @@
+"""
+Mesh dynamic
+============
+
+Example showing a Torus knot, dynamically changing what faces are shown.
+"""
+# sphinx_gallery_pygfx_render = True
+# sphinx_gallery_pygfx_target_name = "disp"
+
+import imageio.v3 as iio
+import pygfx as gfx
+
+
+im = iio.imread("imageio:bricks.jpg")
+tex = gfx.Texture(im, dim=2)
+
+obj = gfx.Mesh(gfx.torus_knot_geometry(1, 0.3, 128, 16), gfx.MeshPhongMaterial(map=tex))
+obj.geometry.texcoords.data[:, 0] *= 10  # stretch the texture
+
+
+def animate():
+    indices = obj.geometry.indices
+    offset = indices.draw_range[0] + 32
+    if offset + 640 >= indices.nitems:
+        offset = 0
+    indices.draw_range = offset, 640
+
+
+if __name__ == "__main__":
+    disp = gfx.Display(before_render=animate, stats=True)
+    disp.show(obj)
diff --git a/pygfx/materials/_mesh.py b/pygfx/materials/_mesh.py
index 0308e67..1c6e78c 100644
--- a/pygfx/materials/_mesh.py
+++ b/pygfx/materials/_mesh.py
@@ -535,7 +535,7 @@ class MeshSliceMaterial(MeshAbstractMaterial):
         which make up the equation: ``ax + by + cz + d = 0`` The plane
         definition applies to the world space (of the scene).
         """
-        return self.uniform_buffer.data["plane"]
+        return tuple(self.uniform_buffer.data["plane"])
 
     @plane.setter
     def plane(self, plane):
@@ -545,7 +545,7 @@ class MeshSliceMaterial(MeshAbstractMaterial):
     @property
     def thickness(self):
         """The thickness of the line to draw the edge of the mesh."""
-        return self.uniform_buffer.data["thickness"]
+        return float(self.uniform_buffer.data["thickness"])
 
     @thickness.setter
     def thickness(self, thickness):
diff --git a/pygfx/renderers/wgpu/_shadowutil.py b/pygfx/renderers/wgpu/_shadowutil.py
index 0204ee2..50ab216 100644
--- a/pygfx/renderers/wgpu/_shadowutil.py
+++ b/pygfx/renderers/wgpu/_shadowutil.py
@@ -118,8 +118,8 @@ def render_wobject_shadow(device, light, wobject, shadow_pass):
     shadow_pass.set_vertex_buffer(
         0,
         ensure_wgpu_object(position_buffer),
-        position_buffer.vertex_byte_range[0],
-        position_buffer.vertex_byte_range[1],
+        position_buffer.draw_range[0] * position_buffer.itemsize,
+        position_buffer.draw_range[1] * position_buffer.itemsize,
     )
 
     wobject_bind_group = get_shadow_bind_group(device, wobject.uniform_buffer)
diff --git a/pygfx/renderers/wgpu/_update.py b/pygfx/renderers/wgpu/_update.py
index 2360547..643dd6b 100644
--- a/pygfx/renderers/wgpu/_update.py
+++ b/pygfx/renderers/wgpu/_update.py
@@ -63,7 +63,7 @@ def _update_buffer(buffer):
 
     # Prepare for data uploads
     device = get_shared().device
-    bytes_per_item = buffer.nbytes // buffer.nitems
+    bytes_per_item = buffer.itemsize
 
     # Upload any pending data
     for offset, size in pending_uploads:
diff --git a/pygfx/renderers/wgpu/lineshader.py b/pygfx/renderers/wgpu/lineshader.py
index aa7dafb..a452dcd 100644
--- a/pygfx/renderers/wgpu/lineshader.py
+++ b/pygfx/renderers/wgpu/lineshader.py
@@ -115,12 +115,13 @@ class LineShader(WorldObjectShader):
         }
 
     def _get_n(self, positions):
-        return positions.nitems * 5
+        offset, size = positions.draw_range
+        return offset * 5, size * 5
 
     def get_render_info(self, wobject, shared):
         material = wobject.material
         # Determine how many vertices are needed
-        n = self._get_n(wobject.geometry.positions)
+        offset, size = self._get_n(wobject.geometry.positions)
         # Determine in what render passes this objects must be rendered
         render_mask = wobject.render_mask
         if not render_mask:
@@ -149,7 +150,7 @@ class LineShader(WorldObjectShader):
                 else:
                     render_mask = RenderMask.opaque
         return {
-            "indices": (n, 1),
+            "indices": (size, 1, offset, 0),
             "render_mask": render_mask,
         }
 
@@ -465,7 +466,8 @@ class LineSegmentShader(LineShader):
         self["line_type"] = "segment"
 
     def _get_n(self, positions):
-        return (positions.nitems // 2) * 2 * 5
+        offset, size = positions.draw_range
+        return (offset // 2) * 2 * 5, (size // 2) * 2 * 5
 
     def code_vertex_core(self):
         return """
@@ -533,7 +535,8 @@ class LineArrowShader(LineShader):
         self["line_type"] = "arrow"
 
     def _get_n(self, positions):
-        return (positions.nitems // 2) * 2 * 4
+        offset, size = positions.draw_range
+        return (offset // 2) * 2 * 4, (size // 2) * 2 * 4
 
     def code_vertex_core(self):
         return """
@@ -635,6 +638,7 @@ class ThinLineShader(WorldObjectShader):
         }
 
     def get_render_info(self, wobject, shared):
+        offset, size = wobject.geometry.positions.draw_range
         render_mask = wobject.render_mask
         if not render_mask:
             render_mask = RenderMask.all
@@ -646,7 +650,7 @@ class ThinLineShader(WorldObjectShader):
                 else:
                     render_mask = RenderMask.opaque
         return {
-            "indices": (wobject.geometry.positions.nitems, 1),
+            "indices": (size, 1, offset, 0),
             "render_mask": render_mask,
         }
 
diff --git a/pygfx/renderers/wgpu/meshshader.py b/pygfx/renderers/wgpu/meshshader.py
index 63c7ffc..72a66ff 100644
--- a/pygfx/renderers/wgpu/meshshader.py
+++ b/pygfx/renderers/wgpu/meshshader.py
@@ -154,7 +154,9 @@ class MeshShader(WorldObjectShader):
         geometry = wobject.geometry
         material = wobject.material
 
-        n = geometry.indices.data.size
+        offset, size = geometry.indices.draw_range
+        offset, size = 3 * offset, 3 * size
+
         n_instances = 1
         if self["instanced"]:
             n_instances = wobject.instance_buffer.nitems
@@ -181,7 +183,7 @@ class MeshShader(WorldObjectShader):
                 raise RuntimeError(f"Unexpected color mode {self['color_mode']}")
 
         return {
-            "indices": (n, n_instances),
+            "indices": (size, n_instances, offset, 0),
             "render_mask": render_mask,
         }
 
@@ -583,6 +585,7 @@ class MeshNormalLinesShader(MeshShader):
         return d
 
     def get_render_info(self, wobject, shared):
+        # We directly look at the vertex data, so geometry.indices.draw_range is ignored.
         d = super().get_render_info(wobject, shared)
         d["indices"] = wobject.geometry.positions.nitems * 2, d["indices"][1]
         return d
@@ -683,8 +686,10 @@ class MeshSliceShader(WorldObjectShader):
 
     def get_render_info(self, wobject, shared):
         material = wobject.material  # noqa
+        geometry = wobject.geometry
 
-        n = (wobject.geometry.indices.data.size // 3) * 6
+        offset, size = geometry.indices.draw_range
+        offset, size = offset * 6, size * 6
 
         # As long as we don't use alpha for aa in the frag shader, we can use a render_mask of 1 or 2.
         render_mask = wobject.render_mask
@@ -695,7 +700,7 @@ class MeshSliceShader(WorldObjectShader):
                 render_mask = RenderMask.opaque
 
         return {
-            "indices": (n, 1),
+            "indices": (size, 1, offset, 0),
             "render_mask": render_mask,
         }
 
diff --git a/pygfx/renderers/wgpu/pointsshader.py b/pygfx/renderers/wgpu/pointsshader.py
index b5ae10d..88da34e 100644
--- a/pygfx/renderers/wgpu/pointsshader.py
+++ b/pygfx/renderers/wgpu/pointsshader.py
@@ -70,7 +70,8 @@ class PointsShader(WorldObjectShader):
     def get_render_info(self, wobject, shared):
         material = wobject.material
 
-        n = wobject.geometry.positions.nitems * 6
+        offset, size = wobject.geometry.positions.draw_range
+        offset, size = offset * 6, size * 6
 
         render_mask = wobject.render_mask
         if not render_mask:
@@ -92,7 +93,7 @@ class PointsShader(WorldObjectShader):
                 raise RuntimeError(f"Unexpected color mode {self['color_mode']}")
 
         return {
-            "indices": (n, 1),
+            "indices": (size, 1, offset, 0),
             "render_mask": render_mask,
         }
 
diff --git a/pygfx/resources/_base.py b/pygfx/resources/_base.py
index e100e5d..e3d7943 100644
--- a/pygfx/resources/_base.py
+++ b/pygfx/resources/_base.py
@@ -21,6 +21,7 @@ class Resource(Trackable):
     """Resource base class."""
 
     _resource_counts = {}
+    _rev = 0
 
     def __init__(self):
         super().__init__()
diff --git a/pygfx/resources/_buffer.py b/pygfx/resources/_buffer.py
index ef59a73..201776a 100644
--- a/pygfx/resources/_buffer.py
+++ b/pygfx/resources/_buffer.py
@@ -37,7 +37,8 @@ class Buffer(Resource):
         format=None,
     ):
         super().__init__()
-        self._rev = 0
+        Resource._rev += 1
+        self._rev = Resource._rev
         # To specify the buffer size
         # The actual data (optional)
         self._data = None
@@ -61,7 +62,8 @@ class Buffer(Resource):
             self._mem = mem
             the_nbytes = mem.nbytes
             the_nitems = mem.shape[0] if mem.shape else 1
-            self._gfx_pending_uploads.append((0, the_nitems))
+            if the_nitems:
+                self._gfx_pending_uploads.append((0, the_nitems))
             if nbytes is not None and nbytes != the_nbytes:
                 raise ValueError("Given nbytes does not match size of given data.")
             if nitems is not None and nitems != the_nitems:
@@ -78,8 +80,7 @@ class Buffer(Resource):
         self._store.nitems = the_nitems
         self._store.format = format
 
-        # We can use a subset when used as a vertex buffer
-        self._vertex_byte_range = (0, the_nbytes)
+        self.draw_range = 0, the_nitems
 
     @property
     def rev(self):
@@ -116,7 +117,15 @@ class Buffer(Resource):
     @property
     def itemsize(self):
         """The number of bytes for a single item."""
-        return self._store.nbytes // self._store.nitems
+        if self._data is None:
+            # This generic solution fails when nitems is zero
+            return self._store.nbytes // self._store.nitems
+        else:
+            shape = self._data.shape
+            if shape:
+                shape = shape[1:]
+            factor = int(np.prod(shape)) or 1
+            return factor * self._data.itemsize
 
     @property
     def format(self):
@@ -135,15 +144,32 @@ class Buffer(Resource):
 
     @property
     def vertex_byte_range(self):
-        """The offset and size, in bytes, when used as a vertex buffer."""
-        return self._vertex_byte_range
+        raise DeprecationWarning(
+            "vertex_byte_range is deprecated, use draw_range instead."
+        )
 
     @vertex_byte_range.setter
     def vertex_byte_range(self, offset_nbytes):
-        offset, nbytes = int(offset_nbytes[0]), int(offset_nbytes[1])
-        assert offset >= 0
-        assert offset + nbytes <= self.nbytes
-        self._vertex_byte_range = offset, nbytes
+        raise DeprecationWarning(
+            "vertex_byte_range is deprecated, use draw_range instead."
+        )
+
+    @property
+    def draw_range(self):
+        """The range to data (origin, size) expressed in items."""
+        return self._store.draw_range
+
+    @draw_range.setter
+    def draw_range(self, draw_range):
+        origin, size = draw_range
+        origin, size = int(origin), int(size)
+        if not (origin == 0 or 0 < origin < self.nitems):  # note nitems can be 0
+            raise ValueError("draw_range origin out of bounds.")
+        if not (size >= 0 and origin + size <= self.nitems):
+            raise ValueError("draw_range size out of bounds.")
+        self._store.draw_range = origin, size
+        Resource._rev += 1
+        self._rev = Resource._rev
 
     def update_range(self, offset=0, size=2**50):
         """Mark a certain range of the data for upload to the GPU. The
@@ -173,7 +199,8 @@ class Buffer(Resource):
             size = end - offset
         # Limit and apply
         self._gfx_pending_uploads.append((offset, size))
-        self._rev += 1
+        Resource._rev += 1
+        self._rev = Resource._rev
         self._gfx_mark_for_sync()
         # note: this can be smarter, we have logic for chunking in the morph tool
 
diff --git a/pygfx/resources/_texture.py b/pygfx/resources/_texture.py
index 911906d..b1e2e61 100644
--- a/pygfx/resources/_texture.py
+++ b/pygfx/resources/_texture.py
@@ -47,7 +47,8 @@ class Texture(Resource):
         generate_mipmaps=False,
     ):
         super().__init__()
-        self._rev = 0
+        Resource._rev += 1
+        self._rev = Resource._rev
         # The dim specifies the texture dimension
         assert dim in (1, 2, 3)
         self._store.dim = int(dim)
@@ -183,7 +184,8 @@ class Texture(Resource):
                 self._gfx_pending_uploads.append((offset2, size2))
         else:
             self._gfx_pending_uploads.append((offset, size))
-        self._rev += 1
+        Resource._rev += 1
+        self._rev = Resource._rev
         self._gfx_mark_for_sync()
 
     def _size_from_data(self, data, dim, size):


Test patch:
diff --git a/tests/resources/test_buffer.py b/tests/resources/test_buffer.py
new file mode 100644
index 0000000..7a0da3b
--- /dev/null
+++ b/tests/resources/test_buffer.py
@@ -0,0 +1,30 @@
+import numpy as np
+import pygfx as gfx
+
+
+def test_empty_data():
+    b = gfx.Buffer(np.zeros((0, 3), np.float32))
+    assert b.draw_range == (0, 0)
+    assert b.itemsize == 3 * 4
+
+    b = gfx.Buffer(np.zeros((0,), np.float32))
+    assert b.draw_range == (0, 0)
+    assert b.itemsize == 4
+
+    b = gfx.Buffer(np.zeros((0, 4, 5), np.float32))
+    assert b.draw_range == (0, 0)
+    assert b.itemsize == 20 * 4
+
+
+def test_nonempty_data():
+    b = gfx.Buffer(np.zeros((2, 3), np.float32))
+    assert b.draw_range == (0, 2)
+    assert b.itemsize == 3 * 4
+
+    b = gfx.Buffer(np.zeros((2,), np.float32))
+    assert b.draw_range == (0, 2)
+    assert b.itemsize == 4
+
+    b = gfx.Buffer(np.zeros((2, 4, 5), np.float32))
+    assert b.draw_range == (0, 2)
+    assert b.itemsize == 20 * 4