Repository: pygfx/pygfx (python)

Issue:
The meshslice does not honor depth test
The mesh slice visual draws itself behind other objects, so it looks like the depth is not set correctly, or the test is bypassed. This is in current main, so not due to changes in #117.

Pull request description:
Material.opacity
Based on #117, ~will rebase hen that is merged.~ done. Ticks one item off #47.

* [x] Implement opacity property on `Material`.
* [x] Refactor how materials classes pass input args to their supers.`*`
* [x] Add a test to ensure that the material uniforms include all fields from their supers.
* [x] Implement opacity for all material fragment shaders.
* [x] Also closes #120 (mesh slice depth issue).

`*` Mainly, make the base material class create the uniform buffer, and use kwargs in the `__init__` to set default values for properties.

Patch:
diff --git a/examples/mesh_slice.py b/examples/mesh_slice.py
index b75914a..5d893da 100644
--- a/examples/mesh_slice.py
+++ b/examples/mesh_slice.py
@@ -22,7 +22,6 @@ obj2 = gfx.Mesh(geometry, material2)
 scene.add(obj1)
 scene.add(obj2)
 
-
 camera = gfx.PerspectiveCamera(70, 2)
 camera.position.z = 4
 
diff --git a/examples/offscreen.py b/examples/offscreen.py
index a095c1f..1ed6d62 100644
--- a/examples/offscreen.py
+++ b/examples/offscreen.py
@@ -35,11 +35,15 @@ canvas.request_draw(lambda: renderer.render(scene, camera))
 
 if __name__ == "__main__":
 
-    # Invoke a draw and get what we'd normally see on screen as a numpy array
+    # The offscreen canvas has a draw method that returns a numpy array.
+    # Use this to obtain what you normally see on-screen. You should
+    # only use an offscreen canvas for e.g. testing or generating images.
     im1 = canvas.draw()
     print("image from canvas.draw():", im1.shape)  # (480, 640, 4)
 
-    # Use the renderer's to get intermediate results (internal render targets)
+    # The renderer also has a snapshot utility. With this you get a snapshot
+    # of the internal state (might be at a higher resolution).
+    # The use of the snapshot method may change and be improved.
     im2 = renderer.snapshot()
     print("Image from renderer.snapshot():", im2.shape)  # (960, 1280, 4)
 
diff --git a/pygfx/materials/_background.py b/pygfx/materials/_background.py
index c5bf2bf..8db045e 100644
--- a/pygfx/materials/_background.py
+++ b/pygfx/materials/_background.py
@@ -1,6 +1,4 @@
 from ._base import Material
-from ..utils import array_from_shadertype
-from ..resources import Buffer
 
 # todo: in ThreeJS you can simply set a CubeTexture as the scene.background
 # we could do that, and the scene could use these objects automatically.
@@ -17,15 +15,11 @@ class BackgroundMaterial(Material):
         color_bottom_right=("float32", 4),
         color_top_left=("float32", 4),
         color_top_right=("float32", 4),
+        opacity=("float32",),
     )
 
-    def __init__(self, *colors):
-        super().__init__()
-
-        self.uniform_buffer = Buffer(
-            array_from_shadertype(self.uniform_type), usage="UNIFORM"
-        )
-
+    def __init__(self, *colors, **kwargs):
+        super().__init__(**kwargs)
         self.set_color(*colors)
 
     def set_color(self, *colors):
@@ -105,8 +99,8 @@ class BackgroundImageMaterial(BackgroundMaterial):
     Use the Background object's transform to orient the image.
     """
 
-    def __init__(self, map=None):
-        super().__init__()
+    def __init__(self, map=None, **kwargs):
+        super().__init__(**kwargs)
         self.map = map
 
     @property
diff --git a/pygfx/materials/_base.py b/pygfx/materials/_base.py
index 7f3dbd2..c54909b 100644
--- a/pygfx/materials/_base.py
+++ b/pygfx/materials/_base.py
@@ -1,4 +1,6 @@
 from ..objects._base import ResourceContainer
+from ..utils import array_from_shadertype
+from ..resources import Buffer
 
 
 class Material(ResourceContainer):
@@ -6,7 +8,18 @@ class Material(ResourceContainer):
     Materials define how an object is rendered, subject to certain properties.
     """
 
-    uniform_type = {}
+    uniform_type = dict(
+        opacity=("float32",),
+    )
+
+    def __init__(self, *, opacity=1):
+        super().__init__()
+
+        self.uniform_buffer = Buffer(
+            array_from_shadertype(self.uniform_type), usage="UNIFORM"
+        )
+
+        self.opacity = opacity
 
     def _wgpu_get_pick_info(self, pick_value):
         """Given a 4 element tuple, sampled from the pick texture,
@@ -16,3 +29,16 @@ class Material(ResourceContainer):
         """
         # Note that this is a private friend-method of the renderer.
         return {}
+
+    @property
+    def opacity(self):
+        """The opacity (a.k.a. alpha value) applied to this material, expressed
+        as a value between 0 and 1. If the material contains any
+        non-opaque fragments, these are simply scaled by this value.
+        """
+        return float(self.uniform_buffer.data["opacity"])
+
+    @opacity.setter
+    def opacity(self, value):
+        self.uniform_buffer.data["opacity"] = min(max(float(value), 0), 1)
+        self.uniform_buffer.update_range(0, 1)
diff --git a/pygfx/materials/_line.py b/pygfx/materials/_line.py
index baf80e7..7c353cd 100644
--- a/pygfx/materials/_line.py
+++ b/pygfx/materials/_line.py
@@ -1,6 +1,4 @@
 from ._base import Material
-from ..utils import array_from_shadertype
-from ..resources import Buffer
 
 
 class LineMaterial(Material):
@@ -9,17 +7,16 @@ class LineMaterial(Material):
     uniform_type = dict(
         color=("float32", 4),
         thickness=("float32",),
+        opacity=("float32",),
     )
 
-    def __init__(self, color=(1, 1, 1, 1), thickness=2.0, vertex_colors=False):
-        super().__init__()
-
-        self.uniform_buffer = Buffer(
-            array_from_shadertype(self.uniform_type), usage="UNIFORM"
-        )
-        self.set_color(color)
-        self.set_thickness(thickness)
+    def __init__(
+        self, color=(1, 1, 1, 1), thickness=2.0, vertex_colors=False, **kwargs
+    ):
+        super().__init__(**kwargs)
 
+        self.color = color
+        self.thickness = thickness
         self._vertex_colors = vertex_colors
 
     def _wgpu_get_pick_info(self, pick_value):
@@ -33,7 +30,8 @@ class LineMaterial(Material):
     def color(self):
         return self.uniform_buffer.data["color"]
 
-    def set_color(self, color):
+    @color.setter
+    def color(self, color):
         self.uniform_buffer.data["color"] = tuple(color)
         self.uniform_buffer.update_range(0, 1)
 
@@ -53,7 +51,8 @@ class LineMaterial(Material):
         """The line thickness expressed in logical pixels."""
         return self.uniform_buffer.data["thickness"]
 
-    def set_thickness(self, thickness):
+    @thickness.setter
+    def thickness(self, thickness):
         self.uniform_buffer.data["thickness"] = thickness
         self.uniform_buffer.update_range(0, 1)
 
diff --git a/pygfx/materials/_mesh.py b/pygfx/materials/_mesh.py
index 947112e..6ef93d4 100644
--- a/pygfx/materials/_mesh.py
+++ b/pygfx/materials/_mesh.py
@@ -1,5 +1,3 @@
-from ..utils import array_from_shadertype
-from ..resources import Buffer
 from ._base import Material
 
 
@@ -8,26 +6,18 @@ class MeshBasicMaterial(Material):
     wireframe) way. This material is not affected by lights.
     """
 
-    uniform_type = {
-        "color": ("float32", (4,)),
-        "clim": ("float32", (2,)),
-    }
-
-    def __init__(self, **kwargs):
-        super().__init__()
-
-        self.uniform_buffer = Buffer(
-            array_from_shadertype(self.uniform_type), usage="UNIFORM"
-        )
+    uniform_type = dict(
+        color=("float32", 4),
+        clim=("float32", 2),
+        opacity=("float32",),
+    )
 
-        self._map = None
-        self.color = 1, 1, 1, 1
-        self.clim = 0, 1
+    def __init__(self, color=(1, 1, 1, 1), clim=(0, 1), map=None, **kwargs):
+        super().__init__(**kwargs)
 
-        for argname, val in kwargs.items():
-            if not hasattr(self, argname):
-                raise AttributeError(f"No attribute '{argname}'")
-            setattr(self, argname, val)
+        self.color = color
+        self.clim = clim
+        self.map = map
 
     def _wgpu_get_pick_info(self, pick_value):
         inst = pick_value[1]
@@ -110,10 +100,13 @@ class MeshSliceMaterial(MeshBasicMaterial):
         plane=("float32", 4),
         clim=("float32", 2),
         thickness=("float32",),
+        opacity=("float32",),
     )
 
     def __init__(self, plane=(0, 0, 1, 0), thickness=2.0, **kwargs):
-        super().__init__(plane=plane, thickness=thickness, **kwargs)
+        super().__init__(**kwargs)
+        self.plane = plane
+        self.thickness = thickness
 
     @property
     def plane(self):
diff --git a/pygfx/materials/_points.py b/pygfx/materials/_points.py
index 5c112bb..390310a 100644
--- a/pygfx/materials/_points.py
+++ b/pygfx/materials/_points.py
@@ -1,6 +1,4 @@
 from ._base import Material
-from ..utils import array_from_shadertype
-from ..resources import Buffer
 
 
 class PointsMaterial(Material):
@@ -8,23 +6,18 @@ class PointsMaterial(Material):
     of the given size and color.
     """
 
-    uniform_type = dict(color=("float32", 4), size=("float32",))
+    uniform_type = dict(
+        color=("float32", 4),
+        size=("float32",),
+        opacity=("float32",),
+    )
 
-    def __init__(self, **kwargs):
-        super().__init__()
-
-        self.uniform_buffer = Buffer(
-            array_from_shadertype(self.uniform_type), usage="UNIFORM"
-        )
+    def __init__(self, color=(1, 1, 1, 1), size=1, **kwargs):
+        super().__init__(**kwargs)
 
         self._map = None
-        self.color = 1, 1, 1, 1
-        self.size = 1
-
-        for argname, val in kwargs.items():
-            if not hasattr(self, argname):
-                raise AttributeError(f"No attribute '{argname}'")
-            setattr(self, argname, val)
+        self.color = color
+        self.size = size
 
     def _wgpu_get_pick_info(self, pick_value):
         # The instance is zero while renderer doesn't support instancing
@@ -60,6 +53,7 @@ class PointsMaterial(Material):
     @size.setter
     def size(self, size):
         self.uniform_buffer.data["size"] = size
+        self.uniform_buffer.update_range(0, 1)
 
     # todo: sizeAttenuation
 
diff --git a/pygfx/materials/_volume.py b/pygfx/materials/_volume.py
index c53d444..d1ff21b 100644
--- a/pygfx/materials/_volume.py
+++ b/pygfx/materials/_volume.py
@@ -1,5 +1,3 @@
-from ..utils import array_from_shadertype
-from ..resources import Buffer
 from ._base import Material
 
 
@@ -8,22 +6,14 @@ class VolumeBasicMaterial(Material):
 
     uniform_type = dict(
         clim=("float32", 2),
+        opacity=("float32",),
     )
 
-    def __init__(self, **kwargs):
-        super().__init__()
+    def __init__(self, clim=(0, 1), map=None, **kwargs):
+        super().__init__(**kwargs)
 
-        self.uniform_buffer = Buffer(
-            array_from_shadertype(self.uniform_type), usage="UNIFORM"
-        )
-
-        self._map = None
-        self.clim = 0, 1
-
-        for argname, val in kwargs.items():
-            if not hasattr(self, argname):
-                raise AttributeError(f"No attribute '{argname}'")
-            setattr(self, argname, val)
+        self.clim = clim
+        self.map = map
 
     def _wgpu_get_pick_info(self, pick_value):
         size = self.map.size
@@ -58,8 +48,13 @@ class VolumeSliceMaterial(VolumeBasicMaterial):
     uniform_type = dict(
         plane=("float32", 4),
         clim=("float32", 2),
+        opacity=("float32",),
     )
 
+    def __init__(self, plane=(0, 0, 1, 0), **kwargs):
+        super().__init__(**kwargs)
+        self.plane = plane
+
     @property
     def plane(self):
         """The plane to slice at, represented with 4 floats ``(a, b, c, d)``,
diff --git a/pygfx/renderers/wgpu/backgroundrender.py b/pygfx/renderers/wgpu/backgroundrender.py
index 64a387b..e7fbf0f 100644
--- a/pygfx/renderers/wgpu/backgroundrender.py
+++ b/pygfx/renderers/wgpu/backgroundrender.py
@@ -157,6 +157,7 @@ class BackgroundShader(BaseShader):
                     + u_material.color_top_right * f.x * f.y
                 );
             $$ endif
+            out.color.a = out.color.a * u_material.opacity;
             return out;
         }
         """
diff --git a/pygfx/renderers/wgpu/linerender.py b/pygfx/renderers/wgpu/linerender.py
index c2d05d8..515be04 100644
--- a/pygfx/renderers/wgpu/linerender.py
+++ b/pygfx/renderers/wgpu/linerender.py
@@ -510,6 +510,7 @@ class LineShader(BaseShader):
             let vi = i32(vf + 0.5);
             out.pick = vec4<i32>(u_wobject.id, 0, vi, i32((vf - f32(vi)) * 1048576.0));
 
+            out.color.a = out.color.a * u_material.opacity;
             return out;
         }
         """
diff --git a/pygfx/renderers/wgpu/meshrender.py b/pygfx/renderers/wgpu/meshrender.py
index d822429..ff375de 100644
--- a/pygfx/renderers/wgpu/meshrender.py
+++ b/pygfx/renderers/wgpu/meshrender.py
@@ -387,6 +387,8 @@ class MeshShader(BaseShader):
             let face_id = vec2<i32>(in.face_idx.xz * 10000.0 + in.face_idx.yw + 0.5);  // inst+face
             let w8 = vec3<i32>(in.face_coords.xyz * 255.0 + 0.5);
             out.pick = vec4<i32>(u_wobject.id, face_id, w8.x * 65536 + w8.y * 256 + w8.z);
+
+            out.color.a = out.color.a * u_material.opacity;
             return out;
         }
 
@@ -541,7 +543,6 @@ class MeshSliceShader(BaseShader):
         struct FragmentOutput {
             [[location(0)]] color: vec4<f32>;
             [[location(1)]] pick: vec4<i32>;
-            [[builtin(frag_depth)]] depth: f32;
         };
 
         [[block]]
@@ -745,6 +746,7 @@ class MeshSliceShader(BaseShader):
             let w8 = vec3<i32>(in.face_coords.xyz * 255.0 + 0.5);
             out.pick = vec4<i32>(u_wobject.id, face_id, w8.x * 65536 + w8.y * 256 + w8.z);
 
+            out.color.a = out.color.a * u_material.opacity;
             return out;
         }
         """
diff --git a/pygfx/renderers/wgpu/pointsrender.py b/pygfx/renderers/wgpu/pointsrender.py
index 80812d7..c71cbc6 100644
--- a/pygfx/renderers/wgpu/pointsrender.py
+++ b/pygfx/renderers/wgpu/pointsrender.py
@@ -166,6 +166,7 @@ class PointsShader(BaseShader):
             let vertex_id = i32(in.vertex_idx.x * 10000.0 + in.vertex_idx.y + 0.5);
             out.pick = vec4<i32>(u_wobject.id, 0, vertex_id, 0);
 
+            out.color.a = out.color.a * u_material.opacity;
             return out;
         }
         """
diff --git a/pygfx/renderers/wgpu/volumerender.py b/pygfx/renderers/wgpu/volumerender.py
index 692c44f..f4eab6e 100644
--- a/pygfx/renderers/wgpu/volumerender.py
+++ b/pygfx/renderers/wgpu/volumerender.py
@@ -320,6 +320,8 @@ class VolumeSliceShader(BaseShader):
 
             out.color = vec4<f32>(albeido, color_value.a);
             out.pick = vec4<i32>(u_wobject.id, vec3<i32>(in.texcoord * 1048576.0 + 0.5));
+
+            out.color.a = out.color.a * u_material.opacity;
             return out;
         }
         """


Test patch:
diff --git a/tests/objects/test_materials.py b/tests/objects/test_materials.py
new file mode 100644
index 0000000..e90914a
--- /dev/null
+++ b/tests/objects/test_materials.py
@@ -0,0 +1,30 @@
+import pygfx
+
+
+# Collect materials
+material_classes = []
+for name in dir(pygfx.materials):
+    val = getattr(pygfx.materials, name)
+    if isinstance(val, type) and issubclass(val, pygfx.Material):
+        material_classes.append(val)
+
+
+def test_uniform_types_uniform_type():
+    """Check that the uniform_type always includes that of the super."""
+    for cls in material_classes:
+        assert isinstance(cls.uniform_type, dict)
+        for super_cls in cls.mro():
+            if super_cls is cls:
+                continue
+            elif not hasattr(super_cls, "uniform_type"):
+                break
+            else:
+                for key, val in super_cls.uniform_type.items():
+                    assert key in cls.uniform_type, f"{cls.__name__}:{key} missing"
+                    assert (
+                        val == cls.uniform_type[key]
+                    ), f"{cls.__name__}:{key} different"
+
+
+if __name__ == "__main__":
+    test_uniform_types_uniform_type()