Repository: pygfx/pygfx
Pull request #198 (2021-11-01 20:40:55)

OIT step 1: Refactor renderer
This refactors the wgpu render code to prepare for implementing OIT, as planned in #196.

Todo:
* [x] Refactor so all blending stuff is defined in a single place.
* [x] More refactoring to support 1 or 2 render passes. 
* [x] Implement render mode that we had before this.
* [x] Implement simple 2-pass render mode.
* [x] Add `renderer.blend_mode`.
* [x] Decide how to implement the two fragment entry points with the shader (templates). Depends on https://github.com/gfx-rs/naga/issues/1509 -> see #200 
* [x] Implement for all materials.
* [x] Fix example to use PySide6.
* [x] Remove the `with open("c:/dev/py/tmp.wgsl")` thing.
* [x] Self-review + check for todo's.
* [x] Relief limitation that varying assignment must be a oneliner
* [x] Cleanup


berendkleinhaneveld: Well, you could at least call it `FragmentBlender` :)
almarklein: Still refactoring and cleaning up, but here are some results for three initial blend modes.

The "solid" blend mode pretends that there is no such thing as transparency. Only included as a stub. It has one benefit: it's order independent :P

![image](https://user-images.githubusercontent.com/3015475/140046828-a33cd757-855e-41f1-b0a7-32a3162dee3a.png)

The "minimal" blend mode uses one pass, processing opaque and transparent fragments at the same time, while depth-testing. This is basically what we had. You can see how the red plane (the first-drawn) hides the other planes behind it because of the depth test. Also the blue plane (drawn last) disappears behind the green plane. Oh, and the sphere is partially hidden too.

![image](https://user-images.githubusercontent.com/3015475/140048522-71b16155-cb55-44f8-b11f-001ce2143737.png)

The "simple" blend mode uses two passes. One for opaque and one for transparent fragments. The looks result is much better. However, it's not order-independent, as can be seen in the second image.

![image](https://user-images.githubusercontent.com/3015475/140048199-0195748c-3ab4-47e5-ad2e-c1f080742ea6.png)
![transparency01](https://user-images.githubusercontent.com/3015475/140049158-cecb6564-145e-410c-80f0-8751d5767639.gif)
)

Korijn: Just wondering if we are still sorting objects along their depth in camera space prior to rendering? That way at least the result will be deterministic, though still order-dependent.
almarklein: I think we are.
almarklein: I'm now happy with the approach to deal with the shaders that run (slightly changed) in multiple render passes. See #200 for details. Other than that, we just create two wgsl samples, and two wgpu shader modules: one for each render pass. I initially wanted to include all entrypoints into the same shader, but that quickly becomes complicated to implement and explain.


almarklein: This is ready from my end.

Some updates:
* I implemented the new approach for all material-renderers! Ran into a few hurdles though:
* Since the blend mode affects the pipeline of the wobjects, the pipeline data could no longer be stored on the wobjects themselves (it would go wrong if using one object in multiple renders with different blend modes). Therefore the pipeline-data is now associated via weak-ref dicts. For details see the extensive comment in _renderer.py.
* The blender defines the `FragmentOutput`, but it does not know beforehand whether the eventual shader wants to also write depth. I added another (much simpler) wgsl post processing pass to resolve this.
* The varyings resolve post-processing now deals with multiline assignments.

Overall, this refactoring is mainly a preparation for more advanced blend modes, adding support for multi-stage rendering. Because we now have a 2 pass blend mode, even though its still simple/wrong, it already yields much better results for scenes that contain transparent objects. It also deals much better with e.g. the semitransparent edges of our lines (for aa); our lines look much better now!
Korijn: > * Since the blend mode affects the pipeline of the wobjects, the pipeline data could no longer be stored on the wobjects themselves (it would go wrong if using one object in multiple renders with different blend modes). Therefore the pipeline-data is now associated via weak-ref dicts. For details see the extensive comment in _renderer.py.

Sounds like a big improvement to me!

> * The blender defines the `FragmentOutput`, but it does not know beforehand whether the eventual shader wants to also write depth. I added another (much simpler) wgsl post processing pass to resolve this.

How should I feel about this? :)

> * The varyings resolve post-processing now deals with multiline assignments.

Awesome!

> Overall, this refactoring is mainly a preparation for more advanced blend modes, adding support for multi-stage rendering. Because we now have a 2 pass blend mode, even though its still simple/wrong, it already yields much better results for scenes that contain transparent objects. It also deals much better with e.g. the semitransparent edges of our lines (for aa); our lines look much better now!

Let's merge and continue in another PR!
almarklein: > How should I feel about this? :)

It means we can write:
```
var out = finalize_fragment();
out.depth = ndc_pos.z / ndc_pos.w;
```
and it Just Works.

We could make it more explicit, e.g.:
```
var out = finalize_fragment_at_depth(ndc_pos.z / ndc_pos.w;);
```

BTW, I think I may replace `add_fragment` and `finalize_fragment` with a single function. Let's revisit then. I'll add a note in the TODO of #196
Korijn: As far as I am concerned we can merge :)
almarklein: Wasn't sure whether @berendkleinhaneveld wanted to have a look.
berendkleinhaneveld: Please go ahead and merge: I'll catch up later!

Patch:
diff --git a/examples/transparency1.py b/examples/transparency1.py
new file mode 100644
index 0000000..e46b914
--- /dev/null
+++ b/examples/transparency1.py
@@ -0,0 +1,51 @@
+"""
+Example showing transparency using three overlapping planes.
+Press space to toggle the order of the planes.
+Press 1,2,3 to select the blend mode.
+"""
+
+import pygfx as gfx
+
+from PySide6 import QtWidgets
+from wgpu.gui.qt import WgpuCanvas
+
+
+class WgpuCanvasWithInputEvents(WgpuCanvas):
+    def keyPressEvent(self, event):  # noqa: N802
+        if not event.text():
+            pass
+        elif event.text() == " ":
+            print("Rotating scene element order")
+            scene.add(scene.children[0])
+            canvas.request_draw()
+        elif event.text() in "0123456789":
+            m = [None, "opaque", "simple1", "simple2"]
+            mode = m[int(event.text())]
+            renderer.blend_mode = mode
+            print("Selecting blend_mode", mode)
+
+
+app = QtWidgets.QApplication([])
+
+canvas = WgpuCanvasWithInputEvents()
+renderer = gfx.renderers.WgpuRenderer(canvas)
+scene = gfx.Scene()
+
+geometry = gfx.plane_geometry(50, 50)
+plane1 = gfx.Mesh(geometry, gfx.MeshBasicMaterial(color=(1, 0, 0, 0.4)))
+plane2 = gfx.Mesh(geometry, gfx.MeshBasicMaterial(color=(0, 1, 0, 0.4)))
+plane3 = gfx.Mesh(geometry, gfx.MeshBasicMaterial(color=(0, 0, 1, 0.4)))
+
+plane1.position.set(-10, -10, 1)
+plane2.position.set(0, 0, 2)
+plane3.position.set(10, 10, 3)
+
+scene.add(plane1, plane2, plane3)
+
+camera = gfx.OrthographicCamera(100, 100)
+
+
+if __name__ == "__main__":
+    print(__doc__)
+    canvas.request_draw(lambda: renderer.render(scene, camera))
+    app.exec()
diff --git a/examples/transparency2.py b/examples/transparency2.py
new file mode 100644
index 0000000..3cb3134
--- /dev/null
+++ b/examples/transparency2.py
@@ -0,0 +1,98 @@
+"""
+Example showing transparency using three orthogonal planes.
+Press space to toggle the order of the planes.
+Press 1,2,3 to select the blend mode.
+"""
+
+import pygfx as gfx
+
+from PySide6 import QtWidgets, QtCore
+from wgpu.gui.qt import WgpuCanvas
+
+
+class WgpuCanvasWithInputEvents(WgpuCanvas):
+    _drag_modes = {QtCore.Qt.RightButton: "pan", QtCore.Qt.LeftButton: "rotate"}
+    _mode = None
+
+    def wheelEvent(self, event):  # noqa: N802
+        controls.zoom(2 ** (event.angleDelta().y() * 0.0015))
+
+    def mousePressEvent(self, event):  # noqa: N802
+        mode = self._drag_modes.get(event.button(), None)
+        if self._mode or not mode:
+            return
+        self._mode = mode
+        drag_start = (
+            controls.pan_start if self._mode == "pan" else controls.rotate_start
+        )
+        drag_start(
+            (event.position().x(), event.position().y()),
+            self.get_logical_size(),
+            camera,
+        )
+        app.setOverrideCursor(QtCore.Qt.ClosedHandCursor)
+
+    def keyPressEvent(self, event):  # noqa: N802
+        if not event.text():
+            pass
+        elif event.text() == " ":
+            print("Rotating scene element order")
+            scene.add(scene.children[0])
+        elif event.text() in "0123456789":
+            m = [None, "opaque", "simple1", "simple2"]
+            mode = m[int(event.text())]
+            renderer.blend_mode = mode
+            print("Selecting blend_mode", mode)
+
+    def mouseReleaseEvent(self, event):  # noqa: N802
+        if self._mode and self._mode == self._drag_modes.get(event.button(), None):
+            self._mode = None
+            drag_stop = (
+                controls.pan_stop if self._mode == "pan" else controls.rotate_stop
+            )
+            drag_stop()
+            app.restoreOverrideCursor()
+
+    def mouseMoveEvent(self, event):  # noqa: N802
+        if self._mode is not None:
+            drag_move = (
+                controls.pan_move if self._mode == "pan" else controls.rotate_move
+            )
+            drag_move((event.position().x(), event.position().y()))
+
+
+app = QtWidgets.QApplication([])
+
+canvas = WgpuCanvasWithInputEvents()
+canvas._target_fps = 1000
+renderer = gfx.renderers.WgpuRenderer(canvas, show_fps=True)
+scene = gfx.Scene()
+
+sphere = gfx.Mesh(gfx.sphere_geometry(10), gfx.MeshPhongMaterial())
+
+geometry = gfx.plane_geometry(50, 50)
+plane1 = gfx.Mesh(geometry, gfx.MeshBasicMaterial(color=(1, 0, 0, 0.5)))
+plane2 = gfx.Mesh(geometry, gfx.MeshBasicMaterial(color=(0, 1, 0, 0.5)))
+plane3 = gfx.Mesh(geometry, gfx.MeshBasicMaterial(color=(0, 0, 1, 0.5)))
+
+plane1.rotation.set_from_axis_angle(gfx.linalg.Vector3(1, 0, 0), 1.571)
+plane2.rotation.set_from_axis_angle(gfx.linalg.Vector3(0, 1, 0), 1.571)
+plane3.rotation.set_from_axis_angle(gfx.linalg.Vector3(0, 0, 1), 1.571)
+
+scene.add(plane1, plane2, plane3, sphere)
+
+camera = gfx.PerspectiveCamera(70, 16 / 9)
+camera.position.z = 70
+controls = gfx.OrbitControls(camera.position.clone())
+
+
+def animate():
+    controls.update_camera(camera)
+    renderer.render(scene, camera)
+    canvas.request_draw()
+
+
+if __name__ == "__main__":
+    print(__doc__)
+    canvas.request_draw(animate)
+    app.exec()
diff --git a/pygfx/renderers/wgpu/__init__.py b/pygfx/renderers/wgpu/__init__.py
index 9c4e4bf..123c5aa 100644
--- a/pygfx/renderers/wgpu/__init__.py
+++ b/pygfx/renderers/wgpu/__init__.py
@@ -1,7 +1,8 @@
 # flake8: noqa
 
-from ._wgpurenderer import stdinfo_uniform_type
-from ._wgpurenderer import WgpuRenderer, registry, register_wgpu_render_function
+from ._utils import registry, register_wgpu_render_function
+from ._renderer import stdinfo_uniform_type
+from ._renderer import WgpuRenderer
 from . import meshrender
 from . import linerender
 from . import pointsrender
diff --git a/pygfx/renderers/wgpu/_blender.py b/pygfx/renderers/wgpu/_blender.py
new file mode 100644
index 0000000..0a30342
--- /dev/null
+++ b/pygfx/renderers/wgpu/_blender.py
@@ -0,0 +1,599 @@
+import wgpu  # only for flags/enums
+
+from ...utils import array_from_shadertype
+from ._shadercomposer import Binding, BaseShader
+
+
+class BaseFragmentBlender:
+    """Manage how fragments are blended and end up in the final target.
+    All aspects of blending are defined on this class. Subclasses implement
+    a specific blend mode. Each renderer has one blender object.
+    """
+
+    def __init__(self):
+
+        # The size (2D in pixels) of the frame textures.
+        self.size = (0, 0)
+        self.msaa = 1
+
+        # The color texture is rgba8 unorm - not srgb, that's only for the last step.
+        self.color_format = wgpu.TextureFormat.rgba8unorm
+        # The depth buffer is 32 bit - we need that precision.
+        self.depth_format = wgpu.TextureFormat.depth32float
+        # The pick texture has 4 channels: object id, and then 3 more, e.g.
+        # the instance nr, vertex nr and weights.
+        self.pick_format = wgpu.TextureFormat.rgba32sint
+
+    def ensure_target_size(self, device, size):
+        """If necessary, resize render buffers/textures to match the target size."""
+
+        # todo: I feel that the logic to contain and resize the render targets may be better off in a separate class.
+        # I'll look at this in step 2 of OIT, when we get render targets specific to a blend mode.
+        # Then, this class almost static ...
+
+        assert len(size) == 2
+        size = size[0], size[1]
+        if size == self.size:
+            return
+
+        self.size = size
+        tex_size = size + (1,)
+        usg = wgpu.TextureUsage
+
+        for tex_name, usage in [
+            ("color", usg.RENDER_ATTACHMENT | usg.COPY_SRC | usg.TEXTURE_BINDING),
+            ("depth", usg.RENDER_ATTACHMENT | usg.COPY_SRC),
+            ("pick", usg.RENDER_ATTACHMENT | usg.COPY_SRC),
+        ]:
+            format = getattr(self, tex_name + "_format")
+            texture = device.create_texture(
+                size=tex_size, usage=usage, dimension="2d", format=format
+            )
+            setattr(self, tex_name + "_tex", texture)
+            setattr(self, tex_name + "_view", texture.create_view())
+
+    def get_pipeline_targets1(self):
+        """Get the list of fragment targets for device.create_render_pipeline(),
+        for the first render pass.
+        """
+        return [
+            {
+                "format": self.color_format,
+                "blend": {
+                    "alpha": (
+                        wgpu.BlendFactor.one,
+                        wgpu.BlendFactor.zero,
+                        wgpu.BlendOperation.add,
+                    ),
+                    "color": (
+                        wgpu.BlendFactor.one,
+                        wgpu.BlendFactor.zero,
+                        wgpu.BlendOperation.add,
+                    ),
+                },
+                "write_mask": wgpu.ColorWrite.ALL,
+            },
+            {
+                "format": self.pick_format,
+                "blend": None,
+                "write_mask": wgpu.ColorWrite.ALL,
+            },
+        ]
+
+    def get_color_attachments1(self, clear_color):
+        """Get the list of color_attachments for command_encoder.begin_render_pass(),
+        for the first render pass.
+        """
+        # Unlike most methods, this affects the rendering, but not the wobject's pipeline.
+        if clear_color:
+            color_load_value = 0, 0, 0, 0
+            pick_load_value = 0, 0, 0, 0
+        else:
+            color_load_value = wgpu.LoadOp.load
+            pick_load_value = wgpu.LoadOp.load
+
+        return [
+            {
+                "view": self.color_view,
+                "resolve_target": None,
+                "load_value": color_load_value,
+                "store_op": wgpu.StoreOp.store,
+            },
+            {
+                "view": self.pick_view,
+                "resolve_target": None,
+                "load_value": pick_load_value,
+                "store_op": wgpu.StoreOp.store,
+            },
+        ]
+
+    def get_pipeline_targets2(self):
+        """Get the list of fragment targets for device.create_render_pipeline(),
+        for the second render pass.
+        """
+        return []  # Returning [] prevents this pipeline from being created
+
+    def get_color_attachments2(self, clear_color):
+        """Get the list of color_attachments for command_encoder.begin_render_pass(),
+        for the second render pass.
+        """
+        # Unlike most methods, this affects the rendering, but not the wobject's pipeline.
+        return []  # Returning [] prevents this pass from running
+
+    def get_shader_code(self):
+        """Get the shader code to include in the shaders.
+
+        Notes:
+
+        * This code gets injected into the shader, so the material shaders
+          can use add_fragment and finalize_fragment.
+        * This code should define:
+          * FragmentOutput2
+          * add_fragment1, add_fragment2
+          * finalize_fragment1, finalize_fragment2
+        * That <private> means its a mutable global, scoped to the current thread.
+          Like a normal `var`, but sharable across functions.
+        """
+
+        # Default is very minimalistic, no blending nor depth check, just a stub.
+        return """
+        var<private> p_fragment_color : vec4<f32> = vec4<f32>(0.0, 0.0, 0.0, 0.0);
+
+        $$ if render_pass == 1
+
+            struct FragmentOutput {
+                [[location(0)]] color: vec4<f32>;
+                [[location(1)]] pick: vec4<i32>;
+            };
+            fn add_fragment(depth: f32, color: vec4<f32>) {
+                p_fragment_color = color;
+            }
+            fn finalize_fragment() -> FragmentOutput {
+                var out : FragmentOutput;
+                out.color = p_fragment_color;
+                return out;
+            }
+
+        $$ endif
+        """
+
+
+class OpaqueFragmentBlender(BaseFragmentBlender):
+    """A fragment blender that pretends that all surfaces are opaque,
+    even if they're not.
+    """
+
+    def get_shader_code(self):
+        return """
+        var<private> p_fragment_color : vec4<f32> = vec4<f32>(0.0, 0.0, 0.0, 0.0);
+        var<private> p_fragment_depth : f32 = 1.1;
+
+        $$ if render_pass == 1
+
+            struct FragmentOutput {
+                [[location(0)]] color: vec4<f32>;
+                [[location(1)]] pick: vec4<i32>;
+            };
+            fn add_fragment(depth: f32, color: vec4<f32>) {
+                if (depth < p_fragment_depth) {
+                    p_fragment_color = color;
+                    p_fragment_depth = depth;
+                }
+            }
+            fn finalize_fragment() -> FragmentOutput {
+                if (p_fragment_depth > 1.0) { discard; }
+                var out : FragmentOutput;
+                out.color = vec4<f32>(p_fragment_color.rgb, 1.0);  // opaque
+                return out;
+            }
+
+        $$ endif
+        """
+
+
+class Simple1FragmentBlender(BaseFragmentBlender):
+    """A minimal fragment blender that does the minimal approach to blending:
+    use the OVER blend operation, without differentiating between opaque and
+    transparent objects. This is a common approach for applications using a single pass.
+    """
+
+    def get_pipeline_targets1(self):
+        return [
+            {
+                "format": self.color_format,
+                "blend": {
+                    "alpha": (
+                        wgpu.BlendFactor.one,
+                        wgpu.BlendFactor.one_minus_src_alpha,
+                        wgpu.BlendOperation.add,
+                    ),
+                    "color": (
+                        wgpu.BlendFactor.src_alpha,
+                        wgpu.BlendFactor.one_minus_src_alpha,
+                        wgpu.BlendOperation.add,
+                    ),
+                },
+                "write_mask": wgpu.ColorWrite.ALL,
+            },
+            {
+                "format": self.pick_format,
+                "blend": None,
+                "write_mask": wgpu.ColorWrite.ALL,
+            },
+        ]
+
+    def get_shader_code(self):
+        return """
+        var<private> p_fragment_color : vec4<f32> = vec4<f32>(0.0, 0.0, 0.0, 0.0);
+        var<private> p_fragment_depth : f32 = 1.1;
+
+        $$ if render_pass == 1
+
+            struct FragmentOutput {
+                [[location(0)]] color: vec4<f32>;
+                [[location(1)]] pick: vec4<i32>;
+            };
+            fn add_fragment(depth: f32, color: vec4<f32>) {
+                if (depth < p_fragment_depth) {
+                    p_fragment_color = color;
+                    p_fragment_depth = depth;
+                }
+            }
+            fn finalize_fragment() -> FragmentOutput {
+                if (p_fragment_depth > 1.0) { discard; }
+                var out : FragmentOutput;
+                out.color = p_fragment_color;
+                return out;
+            }
+
+        $$ endif
+        """
+
+
+class Simple2FragmentBlender(BaseFragmentBlender):
+    """A first step towards better blending: separating the opaque
+    from the transparent fragments, and blending the latter using the
+    OVER operator. The second step has depth-testing, but no depth-writing.
+    Not order-independent.
+    """
+
+    def get_pipeline_targets2(self):
+        return [
+            {
+                "format": self.color_format,
+                "blend": {
+                    "alpha": (
+                        wgpu.BlendFactor.one,
+                        wgpu.BlendFactor.one_minus_src_alpha,
+                        wgpu.BlendOperation.add,
+                    ),
+                    "color": (
+                        wgpu.BlendFactor.one,
+                        wgpu.BlendFactor.one_minus_src_alpha,
+                        wgpu.BlendOperation.add,
+                    ),
+                },
+                "write_mask": wgpu.ColorWrite.ALL,
+            },
+        ]
+
+    def get_color_attachments2(self, clear_color):
+        return [
+            {
+                "view": self.color_view,
+                "resolve_target": None,
+                "load_value": wgpu.LoadOp.load,
+                "store_op": wgpu.StoreOp.store,
+            },
+        ]
+
+    def get_shader_code(self):
+        return """
+        var<private> p_fragment_color : vec4<f32> = vec4<f32>(0.0, 0.0, 0.0, 0.0);
+        var<private> p_fragment_depth : f32 = 1.1;
+
+        $$ if render_pass == 1
+
+            struct FragmentOutput {
+                [[location(0)]] color: vec4<f32>;
+                [[location(1)]] pick: vec4<i32>;
+            };
+            fn add_fragment(depth: f32, color: vec4<f32>) {
+                if (color.a >= 1.0 && depth < p_fragment_depth) {
+                    p_fragment_color = color;
+                    p_fragment_depth = depth;
+                }
+            }
+            fn finalize_fragment() -> FragmentOutput {
+                if (p_fragment_depth > 1.0) { discard; }
+                var out : FragmentOutput;
+                out.color = vec4<f32>(p_fragment_color.rgb, 1.0);
+                return out;
+            }
+
+        $$ elif render_pass == 2
+
+            struct FragmentOutput {
+                [[location(0)]] color: vec4<f32>;
+            };
+            fn add_fragment(depth: f32, color: vec4<f32>) {
+                let rgb = (1.0 - color.a) * p_fragment_color.rgb + color.a * color.rgb;
+                let a = (1.0 - color.a) * p_fragment_color.a + color.a;
+                p_fragment_color = vec4<f32>(rgb, a);
+            }
+            fn finalize_fragment() -> FragmentOutput {
+                if (p_fragment_color.a <= 0.0) { discard; }
+                var out : FragmentOutput;
+                out.color = p_fragment_color;
+                return out;
+            }
+
+        $$ endif
+        """
+
+
+# TODO: I think that the blender will always write the end-result back into the color texture
+# which means that the flush step is unaffected. In that case we should move the code elsewhere.
+# There is a small chance that the flush becomes part of the blend resolve step though.
+
+
+class FinalShader(BaseShader):
+    """The shader for the final render step (the flushing to a texture)."""
+
+    def __init__(self):
+        super().__init__()
+        self["tex_coord_map"] = ""
+        self["color_map"] = ""
+
+    def get_code(self):
+        return (
+            self.get_definitions()
+            + """
+
+        struct VertexOutput {
+            [[location(0)]] texcoord: vec2<f32>;
+            [[builtin(position)]] position: vec4<f32>;
+        };
+
+        [[group(0), binding(1)]]
+        var r_sampler: sampler;
+        [[group(0), binding(2)]]
+        var r_tex: texture_2d<f32>;
+
+        [[stage(vertex)]]
+        fn vs_main([[builtin(vertex_index)]] index: u32) -> VertexOutput {
+            var positions = array<vec2<f32>, 4>(vec2<f32>(0.0, 1.0), vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 1.0), vec2<f32>(1.0, 0.0));
+            let pos = positions[index];
+            var out: VertexOutput;
+            out.texcoord = vec2<f32>(pos.x, 1.0 - pos.y);
+            out.position = vec4<f32>(pos * 2.0 - 1.0, 0.0, 1.0);
+            return out;
+        }
+
+        [[stage(fragment)]]
+        fn fs_main(in: VertexOutput) -> [[location(0)]] vec4<f32> {
+            // Get info about the smoothing
+            let sigma = u_render.sigma;
+            let support = min(5, u_render.support);
+
+            // Determine distance between pixels in src texture
+            let stepp = vec2<f32>(1.0 / u_render.size.x, 1.0 / u_render.size.y);
+            // Get texcoord, and round it to the center of the source pixels.
+            // Thus, whether the sampler is linear or nearest, we get equal results.
+            var tex_coord = in.texcoord.xy;
+            {{ tex_coord_map }}
+            let ref_coord = vec2<f32>(vec2<i32>(tex_coord / stepp)) * stepp + 0.5 * stepp;
+
+            // Convolve. Here we apply a Gaussian kernel, the weight is calculated
+            // for each pixel individually based on the distance to the actual texture
+            // coordinate. This means that the textures don't even need to align.
+            var val: vec4<f32> = vec4<f32>(0.0, 0.0, 0.0, 0.0);
+            var weight: f32 = 0.0;
+            for (var y:i32 = -support; y <= support; y = y + 1) {
+                for (var x:i32 = -support; x <= support; x = x + 1) {
+                    let coord = ref_coord + vec2<f32>(f32(x), f32(y)) * stepp;
+                    let dist = length((tex_coord - coord) / stepp);  // in src pixels
+                    let t = dist / sigma;
+                    let w = exp(-0.5 * t * t);
+                    val = val + textureSample(r_tex, r_sampler, coord) * w;
+                    weight = weight + w;
+                }
+            }
+            var out = val / weight;
+            {{ color_map }}
+            return out;
+        }
+    """
+        )
+
+
+class RenderFlusher:
+    """
+    Utility to flush (render) the current state of a renderer into a texture.
+    """
+
+    # todo: Once we also have the depth here, we can support things like fog
+
+    uniform_type = dict(
+        size="2xf4",
+        sigma="f4",
+        support="i4",
+    )
+
+    def __init__(self, device):
+        self._shader = FinalShader()
+        self._device = device
+        self._pipelines = {}
+
+        self._uniform_data = array_from_shadertype(self.uniform_type)
+        self._uniform_buffer = self._device.create_buffer(
+            size=self._uniform_data.nbytes,
+            usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST,
+        )
+
+        self._sampler = self._device.create_sampler(
+            label="render sampler",
+            mag_filter="nearest",
+            min_filter="nearest",
+        )
+
+    def render(self, src_color_tex, src_depth_tex, dst_color_tex, dst_format):
+        """Render the (internal) result of the renderer into a texture."""
+        # NOTE: cannot actually use src_depth_tex as a sample texture (BindingCollision)
+        assert src_depth_tex is None
+        assert isinstance(src_color_tex, wgpu.base.GPUTextureView)
+        assert isinstance(dst_color_tex, wgpu.base.GPUTextureView)
+
+        # Recreate pipeline? Use ._internal as a true identifier of the texture view
+        hash = src_color_tex.size, src_color_tex._internal
+        stored_hash = self._pipelines.get(dst_format, ["invalidhash"])[0]
+        if hash != stored_hash:
+            bind_group, render_pipeline = self._create_pipeline(
+                src_color_tex, dst_format
+            )
+            self._pipelines[dst_format] = hash, bind_group, render_pipeline
+
+        self._update_uniforms(src_color_tex, dst_color_tex)
+        self._render(dst_color_tex, dst_format)
+
+    def _update_uniforms(self, src_color_tex, dst_color_tex):
+        # Get factor between texture sizes
+        factor_x = src_color_tex.size[0] / dst_color_tex.size[0]
+        factor_y = src_color_tex.size[1] / dst_color_tex.size[1]
+        factor = (factor_x + factor_y) / 2
+
+        if factor > 1:
+            # The src has higher res, we can do ssaa.
+            sigma = 0.5 * factor
+            support = min(5, int(sigma * 3))
+        else:
+            # The src has lower res, interpolate + smooth.
+            # Smoothing a bit more helps reduce the blockiness.
+            sigma = 1
+            support = 2
+
+        self._uniform_data["size"] = src_color_tex.size[:2]
+        self._uniform_data["sigma"] = sigma
+        self._uniform_data["support"] = support
+
+    def _render(self, dst_color_tex, dst_format):
+        device = self._device
+        _, bind_group, render_pipeline = self._pipelines[dst_format]
+
+        command_encoder = device.create_command_encoder()
+
+        tmp_buffer = device.create_buffer_with_data(
+            data=self._uniform_data,
+            usage=wgpu.BufferUsage.COPY_SRC,
+        )
+        command_encoder.copy_buffer_to_buffer(
+            tmp_buffer, 0, self._uniform_buffer, 0, self._uniform_data.nbytes
+        )
+
+        render_pass = command_encoder.begin_render_pass(
+            color_attachments=[
+                {
+                    "view": dst_color_tex,
+                    "resolve_target": None,
+                    "load_value": (0, 0, 0, 0),  # LoadOp.load or color
+                    "store_op": wgpu.StoreOp.store,
+                }
+            ],
+            depth_stencil_attachment=None,
+            occlusion_query_set=None,
+        )
+        render_pass.set_pipeline(render_pipeline)
+        render_pass.set_bind_group(0, bind_group, [], 0, 99)
+        render_pass.draw(4, 1)
+        render_pass.end_pass()
+        device.queue.submit([command_encoder.finish()])
+
+    def _create_pipeline(self, src_texture_view, dst_format):
+
+        device = self._device
+
+        shader = self._shader
+        shader.define_binding(
+            0, 0, Binding("u_render", "buffer/uniform", self._uniform_data.dtype)
+        )
+        shader_module = device.create_shader_module(code=shader.generate_wgsl())
+
+        binding_layouts = [
+            {
+                "binding": 0,
+                "visibility": wgpu.ShaderStage.FRAGMENT,
+                "buffer": {"type": wgpu.BufferBindingType.uniform},
+            },
+            {
+                "binding": 1,
+                "visibility": wgpu.ShaderStage.FRAGMENT,
+                "sampler": {"type": wgpu.SamplerBindingType.filtering},
+            },
+            {
+                "binding": 2,
+                "visibility": wgpu.ShaderStage.FRAGMENT,
+                "texture": {
+                    "sample_type": wgpu.TextureSampleType.float,
+                    "view_dimension": wgpu.TextureViewDimension.d2,
+                    "multisampled": False,
+                },
+            },
+        ]
+        bindings = [
+            {
+                "binding": 0,
+                "resource": {
+                    "buffer": self._uniform_buffer,
+                    "offset": 0,
+                    "size": self._uniform_data.nbytes,
+                },
+            },
+            {"binding": 1, "resource": self._sampler},
+            {"binding": 2, "resource": src_texture_view},
+        ]
+
+        bind_group_layout = device.create_bind_group_layout(entries=binding_layouts)
+        pipeline_layout = device.create_pipeline_layout(
+            bind_group_layouts=[bind_group_layout]
+        )
+        bind_group = device.create_bind_group(
+            layout=bind_group_layout, entries=bindings
+        )
+
+        render_pipeline = device.create_render_pipeline(
+            layout=pipeline_layout,
+            vertex={
+                "module": shader_module,
+                "entry_point": "vs_main",
+                "buffers": [],
+            },
+            primitive={
+                "topology": wgpu.PrimitiveTopology.triangle_strip,
+                "strip_index_format": wgpu.IndexFormat.uint32,
+            },
+            depth_stencil=None,
+            multisample=None,
+            fragment={
+                "module": shader_module,
+                "entry_point": "fs_main",
+                "targets": [
+                    {
+                        "format": dst_format,
+                        "blend": {
+                            "alpha": (
+                                wgpu.BlendFactor.one,
+                                wgpu.BlendFactor.zero,
+                                wgpu.BlendOperation.add,
+                            ),
+                            "color": (
+                                wgpu.BlendFactor.src_alpha,
+                                wgpu.BlendFactor.one_minus_src_alpha,
+                                wgpu.BlendOperation.add,
+                            ),
+                        },
+                    }
+                ],
+            },
+        )
+
+        return bind_group, render_pipeline
diff --git a/pygfx/renderers/wgpu/_pipelinebuilder.py b/pygfx/renderers/wgpu/_pipelinebuilder.py
new file mode 100644
index 0000000..b0dbe6b
--- /dev/null
+++ b/pygfx/renderers/wgpu/_pipelinebuilder.py
@@ -0,0 +1,548 @@
+"""
+"""
+
+import wgpu
+
+from ...resources import Buffer, TextureView
+
+from ._utils import to_vertex_format, to_texture_format
+from ._update import update_resource, ALTTEXFORMAT
+from . import registry
+
+
+def ensure_pipeline(renderer, wobject):
+    """Update the GPU objects associated with the given wobject. Returns
+    quickly if no changes are needed. Only this function is used by the
+    renderer.
+    """
+
+    shared = renderer._shared
+    blender = renderer._blender
+    pipelines = renderer._wobject_pipelines
+    device = shared.device
+
+    # Get wobject_pipeline dict associated with this renderer and wobject
+    wobject_pipeline = pipelines.get(wobject, {})
+
+    # This becomes not-None if we need to update the pipeline dict
+    new_pipeline_infos = None
+
+    # Do we need to recreate the pipeline_objects?
+    if wobject.rev != wobject_pipeline.get("ref", ()):
+        # Create fresh wobject_pipeline
+        wobject_pipeline = {"ref": wobject.rev, "renderable": False, "resources": []}
+        pipelines[wobject] = wobject_pipeline
+        # Create pipeline_info and collect resources
+        new_pipeline_infos = create_pipeline_infos(shared, blender, wobject)
+        if new_pipeline_infos:
+            wobject_pipeline["renderable"] = True
+            wobject_pipeline["resources"] = collect_pipeline_resources(
+                shared, wobject, new_pipeline_infos
+            )
+
+    # Early exit?
+    if not wobject_pipeline["renderable"]:
+        return None
+
+    # Check if we need to update any resources. The number of resources
+    # should typically be small. We could implement a hook in the
+    # resource's rev setter so we only have to check one flag ... or
+    # collect all resources on a rendered .. but let's not optimize
+    # prematurely.
+    for kind, resource in wobject_pipeline["resources"]:
+        our_version = getattr(resource, "_wgpu_" + kind, (-1, None))[0]
+        if resource.rev > our_version:
+            update_resource(device, resource, kind)
+
+    # Create gpu objects?
+    if new_pipeline_infos:
+        new_wobject_pipeline = create_pipeline_objects(
+            shared, blender, wobject, new_pipeline_infos
+        )
+        wobject_pipeline.update(new_wobject_pipeline)
+
+    return wobject_pipeline
+
+
+class RenderInfo:
+    """The type of object passed to each wgpu render function. Contains
+    all the info that it might need:
+    * wobject
+    * stdinfo buffer
+    * blender
+    """
+
+    def __init__(self, *, wobject, stdinfo_uniform, blender):
+        self.wobject = wobject
+        self.stdinfo_uniform = stdinfo_uniform
+        self.blender = blender
+
+
+def create_pipeline_infos(shared, blender, wobject):
+    """Use the render function for this wobject and material,
+    and return a list of dicts representing pipelines in an abstract way.
+    These dicts can then be turned into actual pipeline objects.
+    """
+
+    # Get render function for this world object,
+    # and use it to get a high-level description of pipelines.
+    renderfunc = registry.get_render_function(wobject)
+    if renderfunc is None:
+        raise ValueError(
+            f"Could not get a render function for {wobject.__class__.__name__} "
+            f"with {wobject.material.__class__.__name__}"
+        )
+
+    # Prepare info for the render function
+    render_info = RenderInfo(
+        wobject=wobject, stdinfo_uniform=shared.stdinfo_buffer, blender=blender
+    )
+
+    # Call render function
+    pipeline_infos = renderfunc(render_info)
+    if not pipeline_infos:
+        pipeline_infos = None
+    else:
+        assert isinstance(pipeline_infos, list)
+
+    return pipeline_infos
+
+
+def collect_pipeline_resources(shared, wobject, pipeline_infos):
+
+    pipeline_resources = []  # List, because order matters
+
+    # Collect list of resources. This way we can easily iterate
+    # over dependent resource on each render call. We also set the
+    # usage flag of buffers and textures specified in the pipeline.
+    for pipeline_info in pipeline_infos:
+        assert isinstance(pipeline_info, dict)
+        buffer = pipeline_info.get("index_buffer", None)
+        if buffer is not None:
+            buffer._wgpu_usage |= wgpu.BufferUsage.INDEX
+            pipeline_resources.append(("buffer", buffer))
+        for buffer in pipeline_info.get("vertex_buffers", {}).values():
+            buffer._wgpu_usage |= wgpu.BufferUsage.VERTEX
+            pipeline_resources.append(("buffer", buffer))
+        for key in pipeline_info.keys():
+            if key.startswith("bindings"):
+                resources = pipeline_info[key]
+                if isinstance(resources, dict):
+                    resources = resources.values()
+                for binding in resources:
+                    resource = binding.resource
+                    if binding.type.startswith("buffer/"):
+                        assert isinstance(resource, Buffer)
+                        pipeline_resources.append(("buffer", resource))
+                        if "uniform" in binding.type:
+                            resource._wgpu_usage |= wgpu.BufferUsage.UNIFORM
+                        elif "storage" in binding.type:
+                            resource._wgpu_usage |= wgpu.BufferUsage.STORAGE
+                    elif binding.type.startswith("sampler/"):
+                        assert isinstance(resource, TextureView)
+                        pipeline_resources.append(("sampler", resource))
+                    elif binding.type.startswith("texture/"):
+                        assert isinstance(resource, TextureView)
+                        resource.texture._wgpu_usage |= (
+                            wgpu.TextureUsage.TEXTURE_BINDING
+                        )
+                        pipeline_resources.append(("texture", resource.texture))
+                        pipeline_resources.append(("texture_view", resource))
+                    elif binding.type.startswith("storage_texture/"):
+                        assert isinstance(resource, TextureView)
+                        resource.texture._wgpu_usage |= (
+                            wgpu.TextureUsage.STORAGE_BINDING
+                        )
+                        pipeline_resources.append(("texture", resource.texture))
+                        pipeline_resources.append(("texture_view", resource))
+                    else:
+                        raise RuntimeError(
+                            f"Unknown resource binding {binding.name} of type {binding.type}"
+                        )
+
+    return pipeline_resources
+
+
+def create_pipeline_objects(shared, blender, wobject, pipeline_infos):
+    """Generate wgpu pipeline objects from the list of pipeline info dicts."""
+
+    # Prepare the three kinds of pipelines that we can get
+    compute_pipelines = []
+    render_pipelines = []
+    alt_render_pipelines = []
+
+    # Process each pipeline info object, converting each to a more concrete dict
+    for pipeline_info in pipeline_infos:
+        if "render_shader" in pipeline_info:
+            pipeline = compose_render_pipeline(shared, blender, wobject, pipeline_info)
+            if pipeline_info.get("target", None) is None:
+                render_pipelines.append(pipeline)
+            else:
+                raise NotImplementedError("Alternative render pipelines")
+                alt_render_pipelines.append(pipeline)
+        elif "compute_shader" in pipeline_info:
+            compute_pipelines.append(
+                compose_compute_pipeline(shared, wobject, pipeline_info)
+            )
+        else:
+            raise ValueError(
+                "Did not find compute_shader nor render_shader in pipeline info."
+            )
+
+    return {
+        "compute_pipelines": compute_pipelines,
+        "render_pipelines": render_pipelines,
+        "alt_render_pipelines": alt_render_pipelines,
+    }
+
+
+def compose_compute_pipeline(shared, wobject, pipeline_info):
+    """Given a high-level compute pipeline description, creates a
+    lower-level representation that can be consumed by wgpu.
+    """
+
+    # todo: cache the pipeline with the shader (and entrypoint) as a hash
+
+    device = shared.device
+
+    # Convert indices to args for the compute_pass.dispatch() call
+    indices = pipeline_info["indices"]
+    if not (
+        isinstance(indices, tuple)
+        and len(indices) == 3
+        and all(isinstance(i, int) for i in indices)
+    ):
+        raise RuntimeError(f"Compute indices must be 3-tuple of ints, not {indices}.")
+    index_args = indices
+
+    # Get bind groups and pipeline layout from the buffers in pipeline_info.
+    # This also makes sure the buffers and textures are up-to-date.
+    bind_groups, pipeline_layout = get_bind_groups(shared, pipeline_info)
+
+    # Compile shader and create pipeline object
+    cshader = pipeline_info["compute_shader"]
+    cs_module = device.create_shader_module(code=cshader)
+    compute_pipeline = device.create_compute_pipeline(
+        layout=pipeline_layout,
+        compute={"module": cs_module, "entry_point": "main"},
+    )
+
+    return {
+        "pipeline": compute_pipeline,  # wgpu object
+        "index_args": index_args,  # tuple
+        "bind_groups": bind_groups,  # list of wgpu bind_group objects
+    }
+
+
+def compose_render_pipeline(shared, blender, wobject, pipeline_info):
+    """Given a high-level render pipeline description, creates a
+    lower-level representation that can be consumed by wgpu.
+    """
+
+    # todo: cache the pipeline with a lot of things as the hash
+    # todo: cache vertex descriptors
+
+    device = shared.device
+
+    # If an index buffer is present, update it, and get index_format.
+    wgpu_index_buffer = None
+    index_format = wgpu.IndexFormat.uint32
+    index_buffer = pipeline_info.get("index_buffer", None)
+    if index_buffer is not None:
+        wgpu_index_buffer = index_buffer._wgpu_buffer[1]
+        index_format = to_vertex_format(index_buffer.format)
+        index_format = index_format.split("x")[0].replace("s", "u")
+
+    # Convert and check high-level indices. Indices represent a range
+    # of index id's, or define what indices in the index buffer are used.
+    indices = pipeline_info.get("indices", None)
+    if indices is None:
+        if index_buffer is None:
+            raise RuntimeError("Need indices or index_buffer ")
+        indices = range(index_buffer.data.size)
+    # Convert to 2-element tuple (vertex, instance)
+    if not isinstance(indices, tuple):
+        indices = (indices,)
+    if len(indices) == 1:
+        indices = indices + (1,)  # add instancing index
+    if len(indices) != 2:
+        raise RuntimeError("Render pipeline indices must be a 2-element tuple.")
+
+    # Convert indices to args for the render_pass.draw() or draw_indexed()
+    # draw(count_vertex, count_instance, first_vertex, first_instance)
+    # draw_indexed(count_v, count_i, first_vertex, base_vertex, first_instance)
+    index_args = [0, 0, 0, 0]
+    for i, index in enumerate(indices):
+        if isinstance(index, int):
+            index_args[i] = index
+        elif isinstance(index, range):
+            assert index.step == 1
+            index_args[i] = index.stop - index.start
+            index_args[i + 2] = index.start
+        else:
+            raise RuntimeError(
+                "Render pipeline indices must be a 2-element tuple with ints or ranges."
+            )
+    if wgpu_index_buffer is not None:
+        base_vertex = 0  # A value added to each index before reading [...]
+        index_args.insert(-1, base_vertex)
+
+    # Process vertex buffers. Update the buffer, and produces a descriptor.
+    vertex_buffers = {}
+    vertex_buffer_descriptors = []
+    # todo: we can probably expose multiple attributes per buffer using a BufferView
+    # -> can we also leverage numpy here?
+    for slot, buffer in pipeline_info.get("vertex_buffers", {}).items():
+        slot = int(slot)
+        vbo_des = {
+            "array_stride": buffer.nbytes // buffer.nitems,
+            "step_mode": wgpu.VertexStepMode.vertex,  # vertex or instance
+            "attributes": [
+                {
+                    "format": to_vertex_format(buffer.format),
+                    "offset": 0,
+                    "shader_location": slot,
+                }
+            ],
+        }
+        vertex_buffers[slot] = buffer
+        vertex_buffer_descriptors.append(vbo_des)
+
+    # Get bind groups and pipeline layout from the buffers in pipeline_info.
+    # This also makes sure the buffers and textures are up-to-date.
+    bind_groups, pipeline_layout = get_bind_groups(shared, pipeline_info)
+
+    # todo: is this how strip_index_format is supposed to work?
+    strip_index_format = 0
+    if "strip" in pipeline_info["primitive_topology"]:
+        strip_index_format = index_format
+
+    # Instantiate the pipeline objects
+
+    pipelines = {}
+    for render_pass_iter in [1, 2]:
+        if render_pass_iter == 1:
+            depth_write_enabled = True
+            fragment_targets = blender.get_pipeline_targets1()
+        else:
+            depth_write_enabled = False
+            fragment_targets = blender.get_pipeline_targets2()
+
+        if not fragment_targets:
+            pipelines[render_pass_iter] = None
+            continue
+
+        # Compile shader
+        shader = pipeline_info["render_shader"]
+        wgsl = shader.generate_wgsl(render_pass=render_pass_iter)
+        shader_module = get_shader_module(shared, wgsl)
+
+        pipelines[render_pass_iter] = device.create_render_pipeline(
+            layout=pipeline_layout,
+            vertex={
+                "module": shader_module,
+                "entry_point": "vs_main",
+                "buffers": vertex_buffer_descriptors,
+            },
+            primitive={
+                "topology": pipeline_info["primitive_topology"],
+                "strip_index_format": strip_index_format,
+                "front_face": wgpu.FrontFace.ccw,
+                "cull_mode": pipeline_info.get("cull_mode", wgpu.CullMode.none),
+            },
+            depth_stencil={
+                "format": blender.depth_format,
+                "depth_write_enabled": depth_write_enabled,
+                "depth_compare": wgpu.CompareFunction.less,
+                "stencil_front": {},  # use defaults
+                "stencil_back": {},  # use defaults
+                "depth_bias": 0,
+                "depth_bias_slope_scale": 0.0,
+                "depth_bias_clamp": 0.0,
+            },
+            multisample={
+                "count": blender.msaa,
+                "mask": 0xFFFFFFFF,
+                "alpha_to_coverage_enabled": False,
+            },
+            fragment={
+                "module": shader_module,
+                "entry_point": "fs_main",
+                "targets": fragment_targets,
+            },
+        )
+
+    return {
+        "pipeline1": pipelines[1],  # wgpu object
+        "pipeline2": pipelines[2],  # wgpu object
+        "index_args": index_args,  # tuple
+        "index_buffer": wgpu_index_buffer,  # Buffer
+        "vertex_buffers": vertex_buffers,  # dict of slot -> Buffer
+        "bind_groups": bind_groups,  # list of wgpu bind_group objects
+    }
+
+
+def get_bind_groups(shared, pipeline_info):
+    """Given high-level information on bindings, create the corresponding
+    wgpu objects. This assumes that all buffers and textures are up-to-date.
+    Returns (bind_groups, pipeline_layout).
+    """
+    # todo: cache bind_group_layout objects
+    # todo: cache pipeline_layout objects
+
+    device = shared.device
+
+    # Collect resource groups (keys e.g. "bindings1", "bindings132")
+    resource_groups = []
+    for key in pipeline_info.keys():
+        if key.startswith("bindings"):
+            i = int(key[len("bindings") :])
+            assert i >= 0
+            if not pipeline_info[key]:
+                continue
+            while len(resource_groups) <= i:
+                resource_groups.append({})
+            resource_groups[i] = pipeline_info[key]
+
+    # Create bind groups and bind group layouts
+    bind_groups = []
+    bind_group_layouts = []
+    for resources in resource_groups:
+        if not isinstance(resources, dict):
+            resources = {slot: resource for slot, resource in enumerate(resources)}
+        # Collect list of dicts
+        bindings = []
+        binding_layouts = []
+        for slot, binding in resources.items():
+            resource = binding.resource
+            subtype = binding.type.partition("/")[2]
+
+            if binding.type.startswith("buffer/"):
+                assert isinstance(resource, Buffer)
+                bindings.append(
+                    {
+                        "binding": slot,
+                        "resource": {
+                            "buffer": resource._wgpu_buffer[1],
+                            "offset": 0,
+                            "size": resource.nbytes,
+                        },
+                    }
+                )
+                binding_layouts.append(
+                    {
+                        "binding": slot,
+                        "visibility": binding.visibility,
+                        "buffer": {
+                            "type": getattr(wgpu.BufferBindingType, subtype),
+                            "has_dynamic_offset": False,
+                            "min_binding_size": 0,
+                        },
+                    }
+                )
+            elif binding.type.startswith("sampler/"):
+                assert isinstance(resource, TextureView)
+                bindings.append(
+                    {"binding": slot, "resource": resource._wgpu_sampler[1]}
+                )
+                binding_layouts.append(
+                    {
+                        "binding": slot,
+                        "visibility": binding.visibility,
+                        "sampler": {
+                            "type": getattr(wgpu.SamplerBindingType, subtype),
+                        },
+                    }
+                )
+            elif binding.type.startswith("texture/"):
+                assert isinstance(resource, TextureView)
+                bindings.append(
+                    {"binding": slot, "resource": resource._wgpu_texture_view[1]}
+                )
+                dim = resource.view_dim
+                dim = getattr(wgpu.TextureViewDimension, dim, dim)
+                sample_type = getattr(wgpu.TextureSampleType, subtype, subtype)
+                # Derive sample type from texture
+                if sample_type == "auto":
+                    fmt = to_texture_format(resource.format)
+                    fmt = ALTTEXFORMAT.get(fmt, [fmt])[0]
+                    if "float" in fmt or "norm" in fmt:
+                        sample_type = wgpu.TextureSampleType.float
+                        # For float32 wgpu does not allow the sampler to be filterable,
+                        # except when the native-only feature
+                        # TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES is set,
+                        # which wgpu-py does by default.
+                        # if "32float" in fmt:
+                        #     sample_type = wgpu.TextureSampleType.unfilterable_float
+                    elif "uint" in fmt:
+                        sample_type = wgpu.TextureSampleType.uint
+                    elif "sint" in fmt:
+                        sample_type = wgpu.TextureSampleType.sint
+                    elif "depth" in fmt:
+                        sample_type = wgpu.TextureSampleType.depth
+                    else:
+                        raise ValueError("Could not determine texture sample type.")
+                binding_layouts.append(
+                    {
+                        "binding": slot,
+                        "visibility": binding.visibility,
+                        "texture": {
+                            "sample_type": sample_type,
+                            "view_dimension": dim,
+                            "multisampled": False,
+                        },
+                    }
+                )
+            elif binding.type.startswith("storage_texture/"):
+                assert isinstance(resource, TextureView)
+                bindings.append(
+                    {"binding": slot, "resource": resource._wgpu_texture_view[1]}
+                )
+                dim = resource.view_dim
+                dim = getattr(wgpu.TextureViewDimension, dim, dim)
+                fmt = to_texture_format(resource.format)
+                fmt = ALTTEXFORMAT.get(fmt, [fmt])[0]
+                binding_layouts.append(
+                    {
+                        "binding": slot,
+                        "visibility": binding.visibility,
+                        "storage_texture": {
+                            "access": getattr(wgpu.StorageTextureAccess, subtype),
+                            "format": fmt,
+                            "view_dimension": dim,
+                        },
+                    }
+                )
+
+        # Create wgpu objects
+        bind_group_layout = device.create_bind_group_layout(entries=binding_layouts)
+        bind_group = device.create_bind_group(
+            layout=bind_group_layout, entries=bindings
+        )
+        bind_groups.append(bind_group)
+        bind_group_layouts.append(bind_group_layout)
+
+    # Create pipeline layout object from list of layouts
+    pipeline_layout = device.create_pipeline_layout(
+        bind_group_layouts=bind_group_layouts
+    )
+
+    return bind_groups, pipeline_layout
+
+
+def get_shader_module(shared, source):
+    """Compile a shader module object, or re-use it from the cache."""
+    # todo: also release shader modules that are no longer used
+
+    assert isinstance(source, str)
+    key = source  # or hash(code)
+
+    # AK: this dev snippet is so useful that I leave it as a comment.
+    # After a crash you can run naga on the generated file to get better feedback.
+    # with open(__import__("os").path.expanduser("~/tmp.wgsl"), "wb") as f:
+    #     f.write(source.encode())
+
+    if key not in shared.shader_cache:
+        m = shared.device.create_shader_module(code=source)
+        shared.shader_cache[key] = m
+    return shared.shader_cache[key]
diff --git a/pygfx/renderers/wgpu/_renderer.py b/pygfx/renderers/wgpu/_renderer.py
new file mode 100644
index 0000000..2a4a519
--- /dev/null
+++ b/pygfx/renderers/wgpu/_renderer.py
@@ -0,0 +1,668 @@
+import time
+import weakref
+
+import numpy as np
+import wgpu.backends.rs
+
+from .. import Renderer
+from ...linalg import Matrix4, Vector3
+from ...objects import WorldObject
+from ...cameras import Camera
+from ...resources import Buffer, Texture, TextureView
+from ...utils import array_from_shadertype
+
+from . import _blender as blender_module
+from ._pipelinebuilder import ensure_pipeline
+from ._update import update_buffer, update_texture, update_texture_view
+
+
+# Definition uniform struct with standard info related to transforms,
+# provided to each shader as uniform at slot 0.
+# todo: a combined transform would be nice too, for performance
+# todo: same for ndc_to_world transform (combined inv transforms)
+stdinfo_uniform_type = dict(
+    cam_transform="4x4xf4",
+    cam_transform_inv="4x4xf4",
+    projection_transform="4x4xf4",
+    projection_transform_inv="4x4xf4",
+    physical_size="2xf4",
+    logical_size="2xf4",
+    flipped_winding="i4",  # A bool, really
+)
+
+
+def get_size_from_render_target(target):
+    """Get physical and logical size from a render target."""
+    if isinstance(target, wgpu.gui.WgpuCanvasBase):
+        physical_size = target.get_physical_size()
+        logical_size = target.get_logical_size()
+    elif isinstance(target, Texture):
+        physical_size = target.size[:2]
+        logical_size = physical_size
+    elif isinstance(target, TextureView):
+        physical_size = target.texture.size[:2]
+        logical_size = physical_size
+    else:
+        raise TypeError(f"Unexpected render target {target.__class__.__name__}")
+    return physical_size, logical_size
+
+
+class SharedData:
+    """An object to store global data to share between multiple wgpu renderers.
+
+    Since renderers don't render simultaneously, they can share certain
+    resources. This safes memory, but more importantly, resources that
+    get used in wobject pipelines should be shared to avoid having to
+    constantly recompose the pipelines of wobjects that are rendered by
+    multiple renderers.
+    """
+
+    def __init__(self, canvas):
+
+        # Create adapter and device objects - there should be just one per canvas.
+        # Having a global device provides the benefit that we can draw any object
+        # anywhere.
+        # We do pass the canvas to request_adapter(), so we get an adapter that is
+        # at least compatible with the first canvas that a renderer is create for.
+        self.adapter = wgpu.request_adapter(
+            canvas=canvas, power_preference="high-performance"
+        )
+        self.device = self.adapter.request_device(
+            required_features=[], required_limits={}
+        )
+
+        # Create a uniform buffer for std info
+        self.stdinfo_buffer = Buffer(array_from_shadertype(stdinfo_uniform_type))
+        self.stdinfo_buffer._wgpu_usage |= wgpu.BufferUsage.UNIFORM
+
+        # A cache for shader objects
+        self.shader_cache = {}
+
+
+class WgpuRenderer(Renderer):
+    """Object used to render scenes using wgpu.
+
+    The purpose of a renderer is to render (i.e. draw) a scene to a
+    canvas or texture. It also provides picking, defines the
+    anti-aliasing parameters, and any post processing effects.
+
+    A renderer is directly associated with its target and can only render
+    to that target. Different renderers can render to the same target though.
+
+    It provides a ``.render()`` method that can be called one or more
+    times to render scene. This creates a visual representation that
+    is stored internally, and is finally rendered into its render target
+    (the canvas or texture).
+                                  __________
+                                 | renderer |
+        [scenes] -- render() --> |  state   | -- flush() --> [target]
+                                 |__________|
+
+    The internal visual representation includes things like a depth
+    buffer and is typically at a higher resolution to reduce aliasing
+    effects. Further, the representation may in the future accomodate
+    for proper blending of semitransparent objects.
+
+    The flush-step renders the internal representation into the target
+    texture or canvas, applying anti-aliasing. In the future this is
+    also where fog is applied, as well as any custom post-processing
+    effects.
+
+    Parameters:
+        target (WgpuCanvas or Texture): The target to render to, and what
+            determines the size of the render buffer.
+        pixel_ratio (float, optional): How large the physical size of the render
+            buffer is in relation to the target's physical size, for antialiasing.
+            See the corresponding property for details.
+        show_fps (bool): Whether to display the frames per second. Beware that
+            depending on the GUI toolkit, the canvas may impose a frame rate limit.
+    """
+
+    _shared = None
+
+    _wobject_pipelines_collection = weakref.WeakValueDictionary()
+
+    def __init__(self, target, *, pixel_ratio=None, show_fps=False):
+
+        # Check and normalize inputs
+        if not isinstance(target, (Texture, TextureView, wgpu.gui.WgpuCanvasBase)):
+            raise TypeError(
+                f"Render target must be a canvas or texture (view), not a {target.__class__.__name__}"
+            )
+        self._target = target
+
+        # Process other inputs
+        self.pixel_ratio = pixel_ratio
+        self._show_fps = bool(show_fps)
+
+        # Make sure we have a shared object (the first renderer create it)
+        canvas = target if isinstance(target, wgpu.gui.WgpuCanvasBase) else None
+        if WgpuRenderer._shared is None:
+            WgpuRenderer._shared = SharedData(canvas)
+
+        # Init counter to auto-clear
+        self._renders_since_last_flush = 0
+
+        # Get target format
+        if isinstance(target, wgpu.gui.WgpuCanvasBase):
+            self._canvas_context = self._target.get_context()
+            self._target_tex_format = self._canvas_context.get_preferred_format(
+                self._shared.adapter
+            )
+            # Also configure the canvas
+            self._canvas_context.configure(
+                device=self._shared.device,
+                format=self._target_tex_format,
+                usage=wgpu.TextureUsage.RENDER_ATTACHMENT,
+            )
+        else:
+            self._target_tex_format = self._target.format
+            # Also enable the texture for render and display usage
+            self._target._wgpu_usage |= wgpu.TextureUsage.RENDER_ATTACHMENT
+            self._target._wgpu_usage |= wgpu.TextureUsage.TEXTURE_BINDING
+
+        # Prepare render targets.
+        self.blend_mode = "default"
+
+        # Prepare object that performs the final render step into a texture
+        self._flusher = blender_module.RenderFlusher(self._shared.device)
+
+        # Initialize a small buffer to read pixel info into
+        # Make it 256 bytes just in case (for bytes_per_row)
+        self._pixel_info_buffer = self._shared.device.create_buffer(
+            size=256,
+            usage=wgpu.BufferUsage.COPY_DST | wgpu.BufferUsage.MAP_READ,
+        )
+
+        # Keep track of object ids
+        self._pick_map = weakref.WeakValueDictionary()
+
+    @property
+    def device(self):
+        """A reference to the used wgpu device."""
+        return self._shared.device
+
+    @property
+    def target(self):
+        """The render target. Can be a canvas, texture or texture view."""
+        return self._target
+
+    @property
+    def pixel_ratio(self):
+        """The ratio between the number of internal pixels versus the logical pixels on the canvas.
+
+        This can be used to configure the size of the render texture
+        relative to the canvas' logical size. By default (value is None) the
+        used pixel ratio follows the screens pixel ratio on high-res
+        displays, and is 2 otherwise.
+
+        If the used pixel ratio causes the render texture to be larger
+        than the physical size of the canvas, SSAA is applied, resulting
+        in a smoother final image with less jagged edges. Alternatively,
+        this value can be set to e.g. 0.5 to lower* the resolution (e.g.
+        for performance during interaction).
+        """
+        return self._pixel_ratio
+
+    @pixel_ratio.setter
+    def pixel_ratio(self, value):
+        if value is None:
+            self._pixel_ratio = None
+        elif isinstance(value, (int, float)):
+            self._pixel_ratio = None if value <= 0 else float(value)
+        else:
+            raise TypeError(
+                f"Rendered.pixel_ratio expected None or number, not {value}"
+            )
+
+    @property
+    def blend_mode(self):
+        """The method for handling transparency:
+        * "default" or None: Select the default: currently this is "simple2".
+        * "opaque": single-pass approach that consider every fragment opaque.
+        * "simple1": single-pass approach that blends fragments (using OVER compositing).
+          Can only produce correct results if fragments are drawn from back to front.
+        * "simple2": two-pass approach that first processes all opaque fragments and
+          then blends transparent fragments (using the OVER operator) with depth-write disabled.
+          Yields visually ok results, but is not order independent.
+        * "weighted": todo McGuire 2013
+        * "weighted_z": todo McGuire 2016
+        * "multilayer2": todo 2-layer MLAB + weighted for the rest?
+        """
+        return self._blend_mode
+
+    @blend_mode.setter
+    def blend_mode(self, value):
+        # Massage and check the input
+        if value is None:
+            value = "default"
+        value = value.lower()
+        if value == "default":
+            value = "simple2"
+        # Map string input to a class
+        m = {
+            "opaque": blender_module.OpaqueFragmentBlender,
+            "simple1": blender_module.Simple1FragmentBlender,
+            "simple2": blender_module.Simple2FragmentBlender,
+        }
+        if value not in m:
+            raise ValueError(
+                f"Unknown blend_mode '{value}', use any of {set(m.keys())}"
+            )
+        # Set blender object
+        self._blend_mode = value
+        self._blender = m[value]()
+        # If the blend mode has changed, we may need a new _wobject_pipelines
+        self._set_wobject_pipelines()
+        # If our target is a canvas, request a new draw
+        if isinstance(self._target, wgpu.gui.WgpuCanvasBase):
+            self._target.request_draw()
+
+    def _set_wobject_pipelines(self):
+        # Each WorldObject has associated with it a wobject_pipeline:
+        # a dict that contains the wgpu pipeline objects. This
+        # wobject_pipeline is also associated with the blend_mode,
+        # because the blend mode affects the pipelines.
+        #
+        # Each renderer has ._wobject_pipelines, a dict that maps
+        # wobject -> wobject_pipeline. This dict is a WeakKeyDictionary -
+        # when the wobject is destroyed, the associated pipeline is
+        # collected as well.
+        #
+        # Renderers with the same blend mode can safely share these
+        # wobject_pipeline dicts. Therefore, we make use of a global
+        # collection. Since this global collection is a
+        # WeakValueDictionary, if all renderes stop using a certain
+        # blend mode, the associated pipelines are removed as well.
+        #
+        # In a diagram:
+        #
+        # _wobject_pipelines_collection -> _wobject_pipelines -> wobject_pipeline
+        #        global                         renderer              wobject
+        #   WeakValueDictionary              WeakKeyDictionary         dict
+
+        # Below we set this renderer's _wobject_pipelines. Note that if the
+        # blending has changed, we automatically invalidate all "our" pipelines.
+        self._wobject_pipelines = WgpuRenderer._wobject_pipelines_collection.setdefault(
+            self.blend_mode, weakref.WeakKeyDictionary()
+        )
+
+    def render(
+        self,
+        scene: WorldObject,
+        camera: Camera,
+        *,
+        viewport=None,
+        clear_color=None,
+        clear_depth=None,
+        flush=True,
+    ):
+        """Render a scene with the specified camera as the viewpoint.
+
+        Parameters:
+            scene (WorldObject): The scene to render, a WorldObject that
+                optionally has child objects.
+            camera (Camera): The camera object to use, which defines the
+                viewpoint and view transform.
+            viewport (tuple, optional): The rectangular region to draw into,
+                expressed in logical pixels.
+            clear_color (bool, optional): Whether to clear the color buffer
+                before rendering. By default this is True on the first
+                call to ``render()`` after a flush, and False otherwise.
+            clear_depth (bool, optional): Whether to clear the depth buffer
+                before rendering. By default this is True on the first
+                call to ``render()`` after a flush, and False otherwise.
+            flush (bool, optional): Whether to flush the rendered result into
+                the target (texture or canvas). Default True.
+        """
+        device = self.device
+
+        now = time.perf_counter()  # noqa
+        if self._show_fps:
+            if not hasattr(self, "_fps"):
+                self._fps = now, now, 1
+            elif now > self._fps[0] + 1:
+                print(f"FPS: {self._fps[2]/(now - self._fps[0]):0.1f}")
+                self._fps = now, now, 1
+            else:
+                self._fps = self._fps[0], now, self._fps[2] + 1
+
+        # Define whether to clear color and/or depth
+        if clear_color is None:
+            clear_color = self._renders_since_last_flush == 0
+        clear_color = bool(clear_color)
+        if clear_depth is None:
+            clear_depth = self._renders_since_last_flush == 0
+        clear_depth = bool(clear_depth)
+        self._renders_since_last_flush += 1
+
+        # todo: also note that the fragment shader is (should be) optional
+        #      (e.g. depth only passes like shadow mapping or z prepass)
+
+        # Get logical size (as two floats). This size is constant throughout
+        # all post-processing render passes.
+        target_size, logical_size = get_size_from_render_target(self._target)
+        if not all(x > 0 for x in logical_size):
+            return
+
+        # Determine the physical size of the render texture
+        target_pixel_ratio = target_size[0] / logical_size[0]
+        if self._pixel_ratio:
+            pixel_ratio = self._pixel_ratio
+        else:
+            pixel_ratio = target_pixel_ratio
+            if pixel_ratio <= 1:
+                pixel_ratio = 2.0  # use 2 on non-hidpi displays
+
+        # Determine the physical size of the first and last render pass
+        framebuffer_size = tuple(max(1, int(pixel_ratio * x)) for x in logical_size)
+
+        # Update the render targets
+        self._blender.ensure_target_size(device, framebuffer_size)
+
+        # Get viewport in physical pixels
+        if not viewport:
+            scene_logical_size = logical_size
+            scene_physical_size = framebuffer_size
+            physical_viewport = 0, 0, framebuffer_size[0], framebuffer_size[1], 0, 1
+        elif len(viewport) == 4:
+            scene_logical_size = viewport[2], viewport[3]
+            physical_viewport = [int(i * pixel_ratio + 0.4999) for i in viewport]
+            physical_viewport = tuple(physical_viewport) + (0, 1)
+            scene_physical_size = physical_viewport[2], physical_viewport[3]
+        else:
+            raise ValueError("The viewport must be None or 4 elements (x, y, w, h).")
+
+        # Ensure that matrices are up-to-date
+        scene.update_matrix_world()
+        camera.set_view_size(*scene_logical_size)
+        camera.update_matrix_world()  # camera may not be a member of the scene
+        camera.update_projection_matrix()
+
+        # Get the list of objects to render (visible and having a material)
+        q = self.get_render_list(scene, camera)
+        for wobject in q:
+            self._pick_map[wobject.id] = wobject
+
+        # Update stdinfo uniform buffer object that we'll use during this render call
+        self._update_stdinfo_buffer(camera, scene_physical_size, scene_logical_size)
+
+        # Ensure each wobject has pipeline info, and filter objects that we cannot render
+        wobject_tuples = []
+        for wobject in q:
+            wobject_pipeline = ensure_pipeline(self, wobject)
+            if wobject_pipeline:
+                wobject_tuples.append((wobject, wobject_pipeline))
+
+        # Render the scene graph (to the first texture)
+        command_encoder = device.create_command_encoder()
+        self._render_recording(
+            command_encoder, wobject_tuples, physical_viewport, clear_color, clear_depth
+        )
+        command_buffers = [command_encoder.finish()]
+        device.queue.submit(command_buffers)
+
+        # Flush to target
+        if flush:
+            self.flush()
+
+    def flush(self):
+        """Render the result into the target texture view. This method is
+        called automatically unless you use ``.render(..., flush=False)``.
+        """
+
+        # Note: we could, in theory, allow specifying a custom target here.
+
+        if isinstance(self._target, wgpu.gui.WgpuCanvasBase):
+            raw_texture_view = self._canvas_context.get_current_texture()
+        else:
+            if isinstance(self._target, Texture):
+                texture_view = self._target.get_view()
+            elif isinstance(self._target, TextureView):
+                texture_view = self._target
+            update_texture(self._shared.device, texture_view.texture)
+            update_texture_view(self._shared.device, texture_view)
+            raw_texture_view = texture_view._wgpu_texture_view[1]
+
+        self._flusher.render(
+            self._blender.color_view,
+            None,
+            raw_texture_view,
+            self._target_tex_format,
+        )
+
+        # Reset counter (so we can auto-clear the first next draw)
+        self._renders_since_last_flush = 0
+
+    def _render_recording(
+        self,
+        command_encoder,
+        wobject_tuples,
+        physical_viewport,
+        clear_color,
+        clear_depth,
+    ):
+
+        # You might think that this is slow for large number of world
+        # object. But it is actually pretty good. It does iterate over
+        # all world objects, and over stuff in each object. But that's
+        # it, really.
+        # todo: we may be able to speed this up with render bundles though
+
+        # ----- compute pipelines
+
+        compute_pass = command_encoder.begin_compute_pass()
+
+        for wobject, wobject_pipeline in wobject_tuples:
+            for pinfo in wobject_pipeline["compute_pipelines"]:
+                compute_pass.set_pipeline(pinfo["pipeline"])
+                for bind_group_id, bind_group in enumerate(pinfo["bind_groups"]):
+                    compute_pass.set_bind_group(
+                        bind_group_id, bind_group, [], 0, 999999
+                    )
+                compute_pass.dispatch(*pinfo["index_args"])
+
+        compute_pass.end_pass()
+
+        # ----- render pipelines
+
+        for render_pass_iter in [1, 2]:
+
+            if render_pass_iter == 1:
+                # Render pass 1 renders opaque fragments and picking info
+                color_attachments = self._blender.get_color_attachments1(clear_color)
+                depth_load_value = 1.0 if clear_depth else wgpu.LoadOp.load
+                depth_store_op = wgpu.StoreOp.store
+            else:
+                # Render pass 2 renders transparent fragments, as defined by the blender
+                color_attachments = self._blender.get_color_attachments2(clear_color)
+                depth_load_value = wgpu.LoadOp.load
+                # depth_write_enabled is already False for all objects, but we
+                # also disable it on the pipeline for good measure.
+                depth_store_op = wgpu.StoreOp.discard
+
+            if not color_attachments:
+                continue
+
+            render_pass = command_encoder.begin_render_pass(
+                color_attachments=color_attachments,
+                depth_stencil_attachment={
+                    "view": self._blender.depth_view,
+                    "depth_load_value": depth_load_value,
+                    "depth_store_op": depth_store_op,
+                    "stencil_load_value": wgpu.LoadOp.load,
+                    "stencil_store_op": wgpu.StoreOp.store,
+                },
+                occlusion_query_set=None,
+            )
+            render_pass.set_viewport(*physical_viewport)
+
+            for wobject, wobject_pipeline in wobject_tuples:
+                for pinfo in wobject_pipeline["render_pipelines"]:
+                    render_pass.set_pipeline(pinfo[f"pipeline{render_pass_iter}"])
+                    for slot, vbuffer in pinfo["vertex_buffers"].items():
+                        render_pass.set_vertex_buffer(
+                            slot,
+                            vbuffer._wgpu_buffer[1],
+                            vbuffer.vertex_byte_range[0],
+                            vbuffer.vertex_byte_range[1],
+                        )
+                    for bind_group_id, bind_group in enumerate(pinfo["bind_groups"]):
+                        render_pass.set_bind_group(bind_group_id, bind_group, [], 0, 99)
+                    # Draw with or without index buffer
+                    if pinfo["index_buffer"] is not None:
+                        ibuffer = pinfo["index_buffer"]
+                        render_pass.set_index_buffer(ibuffer, 0, ibuffer.size)
+                        render_pass.draw_indexed(*pinfo["index_args"])
+                    else:
+                        render_pass.draw(*pinfo["index_args"])
+
+            render_pass.end_pass()
+
+    def _update_stdinfo_buffer(self, camera, physical_size, logical_size):
+        # Update the stdinfo buffer's data
+        stdinfo_data = self._shared.stdinfo_buffer.data
+        stdinfo_data["cam_transform"].flat = camera.matrix_world_inverse.elements
+        stdinfo_data["cam_transform_inv"].flat = camera.matrix_world.elements
+        stdinfo_data["projection_transform"].flat = camera.projection_matrix.elements
+        stdinfo_data[
+            "projection_transform_inv"
+        ].flat = camera.projection_matrix_inverse.elements
+        # stdinfo_data["ndc_to_world"].flat = np.linalg.inv(stdinfo_data["cam_transform"] @ stdinfo_data["projection_transform"])
+        stdinfo_data["physical_size"] = physical_size
+        stdinfo_data["logical_size"] = logical_size
+        stdinfo_data["flipped_winding"] = camera.flips_winding
+        # Upload to GPU
+        self._shared.stdinfo_buffer.update_range(0, 1)
+        update_buffer(self._shared.device, self._shared.stdinfo_buffer)
+
+    def get_render_list(self, scene: WorldObject, camera: Camera):
+        """Given a scene object, get a flat list of objects to render."""
+
+        # Collect items
+        def visit(wobject):
+            nonlocal q
+            if wobject.material is not None:
+                q.append(wobject)
+
+        q = []
+        scene.traverse(visit, True)
+
+        # Next, sort them from back-to-front
+        def sort_func(wobject: WorldObject):
+            z = (
+                Vector3()
+                .set_from_matrix_position(wobject.matrix_world)
+                .apply_matrix4(proj_screen_matrix)
+                .z
+            )
+            return wobject.render_order, z
+
+        proj_screen_matrix = Matrix4().multiply_matrices(
+            camera.projection_matrix, camera.matrix_world_inverse
+        )
+        # todo: either revive or remove (leaning for the latter)
+        # q.sort(key=sort_func)
+        return q
+
+    # Picking
+
+    def get_pick_info(self, pos):
+        """Get information about the given window location. The given
+        pos is a 2D point in logical pixels (with the origin at the
+        top-left). Returns a dict with fields:
+
+        * "ndc": The position in normalized device coordinates, the 3d element
+            being the depth (0..1). Can be translated to the position
+            in world coordinates using the camera transforms.
+        * "rgba": The value in the color buffer. All zero's when rendering
+          directly to the screen (bypassing post-processing).
+        * "world_object": the object at that location (provided that
+          the object supports picking).
+        * Additional pick info may be available, depending on the type of
+          object and its material. See the world-object classes for details.
+        """
+
+        # Make pos 0..1, so we can scale it to the render texture
+        _, logical_size = get_size_from_render_target(self._target)
+        float_pos = pos[0] / logical_size[0], pos[1] / logical_size[1]
+
+        can_sample_color = self._blender.color_tex is not None
+
+        # Sample
+        encoder = self.device.create_command_encoder()
+        self._copy_pixel(encoder, self._blender.depth_tex, float_pos, 0)
+        if can_sample_color:
+            self._copy_pixel(encoder, self._blender.color_tex, float_pos, 8)
+        self._copy_pixel(encoder, self._blender.pick_tex, float_pos, 16)
+        queue = self.device.queue
+        queue.submit([encoder.finish()])
+
+        # Collect data from the buffer
+        data = self._pixel_info_buffer.map_read()
+        depth = data[0:4].cast("f")[0]
+        color = tuple(data[8:12].cast("B"))
+        pick_value = tuple(data[16:32].cast("i"))
+        wobject = self._pick_map.get(pick_value[0], None)
+        # Note: the position in world coordinates is not included because
+        # it depends on the camera, but we don't "own" the camera.
+
+        info = {
+            "ndc": (2 * float_pos[0] - 1, 2 * float_pos[1] - 1, depth),
+            "rgba": color if can_sample_color else (0, 0, 0, 0),
+            "world_object": wobject,
+        }
+
+        if wobject and wobject.material is not None:
+            pick_info = wobject._wgpu_get_pick_info(pick_value)
+            info.update(pick_info)
+        return info
+
+    def _copy_pixel(self, encoder, render_texture, float_pos, buf_offset):
+
+        # Map position to the texture index
+        w, h, d = render_texture.size
+        x = max(0, min(w - 1, int(float_pos[0] * w)))
+        y = max(0, min(h - 1, int(float_pos[1] * h)))
+
+        # Note: bytes_per_row must be a multiple of 256.
+        encoder.copy_texture_to_buffer(
+            {
+                "texture": render_texture,
+                "mip_level": 0,
+                "origin": (x, y, 0),
+            },
+            {
+                "buffer": self._pixel_info_buffer,
+                "offset": buf_offset,
+                "bytes_per_row": 256,  # render_texture.bytes_per_pixel,
+                "rows_per_image": 1,
+            },
+            copy_size=(1, 1, 1),
+        )
+
+    def snapshot(self):
+        """Create a snapshot of the currently rendered image."""
+
+        # Prepare
+        device = self._shared.device
+        texture = self._blender.color_tex
+        size = texture.size
+        bytes_per_pixel = 4
+
+        # Note, with queue.read_texture the bytes_per_row limitation does not apply.
+        data = device.queue.read_texture(
+            {
+                "texture": texture,
+                "mip_level": 0,
+                "origin": (0, 0, 0),
+            },
+            {
+                "offset": 0,
+                "bytes_per_row": bytes_per_pixel * size[0],
+                "rows_per_image": size[1],
+            },
+            size,
+        )
+
+        return np.frombuffer(data, np.uint8).reshape(size[1], size[0], 4)
diff --git a/pygfx/renderers/wgpu/_renderutils.py b/pygfx/renderers/wgpu/_renderutils.py
deleted file mode 100644
index 13c6071..0000000
--- a/pygfx/renderers/wgpu/_renderutils.py
+++ /dev/null
@@ -1,290 +0,0 @@
-import wgpu  # only for flags/enums
-
-from ...utils import array_from_shadertype
-from ._shadercomposer import Binding, BaseShader
-
-
-class RenderTexture:
-    """Class used internally to store a texture and meta data."""
-
-    def __init__(self, format):
-        self.format = format
-        self.texture = None
-        self.texture_view = None
-        self.size = (0, 0, 0)
-
-    def ensure_size(self, device, size):
-        """Make sure that the texture has the given size. If necessary,
-        recreates the texture and texture-view objects.
-        """
-        if size != self.size:
-            self.size = size
-            usage = wgpu.TextureUsage.RENDER_ATTACHMENT | wgpu.TextureUsage.COPY_SRC
-            if self.format.startswith(("rgb", "bgr")):
-                usage |= wgpu.TextureUsage.TEXTURE_BINDING
-            self.texture = device.create_texture(
-                size=size, usage=usage, dimension="2d", format=self.format
-            )
-            self.texture_view = self.texture.create_view()
-
-
-class FinalShader(BaseShader):
-    """The shader for the final render step (the flushing to a texture)."""
-
-    def __init__(self):
-        super().__init__()
-        self["tex_coord_map"] = ""
-        self["color_map"] = ""
-
-    def get_code(self):
-        return (
-            self.get_definitions()
-            + """
-
-        struct VertexOutput {
-            [[location(0)]] texcoord: vec2<f32>;
-            [[builtin(position)]] position: vec4<f32>;
-        };
-
-        [[group(0), binding(1)]]
-        var r_sampler: sampler;
-        [[group(0), binding(2)]]
-        var r_tex: texture_2d<f32>;
-
-        [[stage(vertex)]]
-        fn vs_main([[builtin(vertex_index)]] index: u32) -> VertexOutput {
-            var positions = array<vec2<f32>, 4>(vec2<f32>(0.0, 1.0), vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 1.0), vec2<f32>(1.0, 0.0));
-            let pos = positions[index];
-            var out: VertexOutput;
-            out.texcoord = vec2<f32>(pos.x, 1.0 - pos.y);
-            out.position = vec4<f32>(pos * 2.0 - 1.0, 0.0, 1.0);
-            return out;
-        }
-
-        [[stage(fragment)]]
-        fn fs_main(in: VertexOutput) -> [[location(0)]] vec4<f32> {
-            // Get info about the smoothing
-            let sigma = u_render.sigma;
-            let support = min(5, u_render.support);
-
-            // Determine distance between pixels in src texture
-            let stepp = vec2<f32>(1.0 / u_render.size.x, 1.0 / u_render.size.y);
-            // Get texcoord, and round it to the center of the source pixels.
-            // Thus, whether the sampler is linear or nearest, we get equal results.
-            var tex_coord = in.texcoord.xy;
-            {{ tex_coord_map }}
-            let ref_coord = vec2<f32>(vec2<i32>(tex_coord / stepp)) * stepp + 0.5 * stepp;
-
-            // Convolve. Here we apply a Gaussian kernel, the weight is calculated
-            // for each pixel individually based on the distance to the actual texture
-            // coordinate. This means that the textures don't even need to align.
-            var val: vec4<f32> = vec4<f32>(0.0, 0.0, 0.0, 0.0);
-            var weight: f32 = 0.0;
-            for (var y:i32 = -support; y <= support; y = y + 1) {
-                for (var x:i32 = -support; x <= support; x = x + 1) {
-                    let coord = ref_coord + vec2<f32>(f32(x), f32(y)) * stepp;
-                    let dist = length((tex_coord - coord) / stepp);  // in src pixels
-                    let t = dist / sigma;
-                    let w = exp(-0.5 * t * t);
-                    val = val + textureSample(r_tex, r_sampler, coord) * w;
-                    weight = weight + w;
-                }
-            }
-            var out = val / weight;
-            {{ color_map }}
-            return out;
-        }
-    """
-        )
-
-
-class RenderFlusher:
-    """
-    Utility to flush (render) the current state of a renderer into a texture.
-    """
-
-    # todo: Once we also have the depth here, we can support things like fog
-
-    uniform_type = dict(
-        size="2xf4",
-        sigma="f4",
-        support="i4",
-    )
-
-    def __init__(self, device):
-        self._shader = FinalShader()
-        self._device = device
-        self._pipelines = {}
-
-        self._uniform_data = array_from_shadertype(self.uniform_type)
-        self._uniform_buffer = self._device.create_buffer(
-            size=self._uniform_data.nbytes,
-            usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST,
-        )
-
-        self._sampler = self._device.create_sampler(
-            label="render sampler",
-            mag_filter="nearest",
-            min_filter="nearest",
-        )
-
-    def render(self, src_color_tex, src_depth_tex, dst_color_tex, dst_format):
-        """Render the (internal) result of the renderer into a texture."""
-        # NOTE: cannot actually use src_depth_tex as a sample texture (BindingCollision)
-        assert src_depth_tex is None
-        assert isinstance(src_color_tex, wgpu.base.GPUTextureView)
-        assert isinstance(dst_color_tex, wgpu.base.GPUTextureView)
-
-        # Recreate pipeline? Use ._internal as a true identifier of the texture view
-        hash = src_color_tex.size, src_color_tex._internal
-        stored_hash = self._pipelines.get(dst_format, ["invalidhash"])[0]
-        if hash != stored_hash:
-            bind_group, render_pipeline = self._create_pipeline(
-                src_color_tex, dst_format
-            )
-            self._pipelines[dst_format] = hash, bind_group, render_pipeline
-
-        self._update_uniforms(src_color_tex, dst_color_tex)
-        self._render(dst_color_tex, dst_format)
-
-    def _update_uniforms(self, src_color_tex, dst_color_tex):
-        # Get factor between texture sizes
-        factor_x = src_color_tex.size[0] / dst_color_tex.size[0]
-        factor_y = src_color_tex.size[1] / dst_color_tex.size[1]
-        factor = (factor_x + factor_y) / 2
-
-        if factor > 1:
-            # The src has higher res, we can do ssaa.
-            sigma = 0.5 * factor
-            support = min(5, int(sigma * 3))
-        else:
-            # The src has lower res, interpolate + smooth.
-            # Smoothing a bit more helps reduce the blockiness.
-            sigma = 1
-            support = 2
-
-        self._uniform_data["size"] = src_color_tex.size[:2]
-        self._uniform_data["sigma"] = sigma
-        self._uniform_data["support"] = support
-
-    def _render(self, dst_color_tex, dst_format):
-        device = self._device
-        _, bind_group, render_pipeline = self._pipelines[dst_format]
-
-        command_encoder = device.create_command_encoder()
-
-        tmp_buffer = device.create_buffer_with_data(
-            data=self._uniform_data,
-            usage=wgpu.BufferUsage.COPY_SRC,
-        )
-        command_encoder.copy_buffer_to_buffer(
-            tmp_buffer, 0, self._uniform_buffer, 0, self._uniform_data.nbytes
-        )
-
-        render_pass = command_encoder.begin_render_pass(
-            color_attachments=[
-                {
-                    "view": dst_color_tex,
-                    "resolve_target": None,
-                    "load_value": (0, 0, 0, 0),  # LoadOp.load or color
-                    "store_op": wgpu.StoreOp.store,
-                }
-            ],
-            depth_stencil_attachment=None,
-            occlusion_query_set=None,
-        )
-        render_pass.set_pipeline(render_pipeline)
-        render_pass.set_bind_group(0, bind_group, [], 0, 99)
-        render_pass.draw(4, 1)
-        render_pass.end_pass()
-        device.queue.submit([command_encoder.finish()])
-
-    def _create_pipeline(self, src_texture_view, dst_format):
-
-        device = self._device
-
-        shader = self._shader
-        shader.define_binding(
-            0, 0, Binding("u_render", "buffer/uniform", self._uniform_data.dtype)
-        )
-        shader_module = device.create_shader_module(code=shader.generate_wgsl())
-
-        binding_layouts = [
-            {
-                "binding": 0,
-                "visibility": wgpu.ShaderStage.FRAGMENT,
-                "buffer": {"type": wgpu.BufferBindingType.uniform},
-            },
-            {
-                "binding": 1,
-                "visibility": wgpu.ShaderStage.FRAGMENT,
-                "sampler": {"type": wgpu.SamplerBindingType.filtering},
-            },
-            {
-                "binding": 2,
-                "visibility": wgpu.ShaderStage.FRAGMENT,
-                "texture": {
-                    "sample_type": wgpu.TextureSampleType.float,
-                    "view_dimension": wgpu.TextureViewDimension.d2,
-                    "multisampled": False,
-                },
-            },
-        ]
-        bindings = [
-            {
-                "binding": 0,
-                "resource": {
-                    "buffer": self._uniform_buffer,
-                    "offset": 0,
-                    "size": self._uniform_data.nbytes,
-                },
-            },
-            {"binding": 1, "resource": self._sampler},
-            {"binding": 2, "resource": src_texture_view},
-        ]
-
-        bind_group_layout = device.create_bind_group_layout(entries=binding_layouts)
-        pipeline_layout = device.create_pipeline_layout(
-            bind_group_layouts=[bind_group_layout]
-        )
-        bind_group = device.create_bind_group(
-            layout=bind_group_layout, entries=bindings
-        )
-
-        render_pipeline = device.create_render_pipeline(
-            layout=pipeline_layout,
-            vertex={
-                "module": shader_module,
-                "entry_point": "vs_main",
-                "buffers": [],
-            },
-            primitive={
-                "topology": wgpu.PrimitiveTopology.triangle_strip,
-                "strip_index_format": wgpu.IndexFormat.uint32,
-            },
-            depth_stencil=None,
-            multisample=None,
-            fragment={
-                "module": shader_module,
-                "entry_point": "fs_main",
-                "targets": [
-                    {
-                        "format": dst_format,
-                        "blend": {
-                            "alpha": (
-                                wgpu.BlendFactor.one,
-                                wgpu.BlendFactor.zero,
-                                wgpu.BlendOperation.add,
-                            ),
-                            "color": (
-                                wgpu.BlendFactor.src_alpha,
-                                wgpu.BlendFactor.one_minus_src_alpha,
-                                wgpu.BlendOperation.add,
-                            ),
-                        },
-                    }
-                ],
-            },
-        )
-
-        return bind_group, render_pipeline
diff --git a/pygfx/renderers/wgpu/_shadercomposer.py b/pygfx/renderers/wgpu/_shadercomposer.py
index 654e606..91acbd1 100644
--- a/pygfx/renderers/wgpu/_shadercomposer.py
+++ b/pygfx/renderers/wgpu/_shadercomposer.py
@@ -1,10 +1,12 @@
+import re
+
 import jinja2
 import numpy as np
 import wgpu
 
 from ...utils import array_from_shadertype
 from ...resources import Buffer
-from ._conv import to_vertex_format, to_texture_format
+from ._utils import to_vertex_format, to_texture_format
 
 
 jinja_env = jinja2.Environment(
@@ -21,6 +23,12 @@ visibility_render = wgpu.ShaderStage.VERTEX | wgpu.ShaderStage.FRAGMENT
 visibility_all = (
     wgpu.ShaderStage.VERTEX | wgpu.ShaderStage.FRAGMENT | wgpu.ShaderStage.COMPUTE
 )
+varying_types = ["f32", "vec2<f32>", "vec3<f32>", "vec4<f32>"]
+varying_types = (
+    varying_types
+    + [t.replace("f", "i") for t in varying_types]
+    + [t.replace("f", "u") for t in varying_types]
+)
 
 
 class Binding:
@@ -46,6 +54,203 @@ class Binding:
             setattr(self, key, val)
 
 
+re_varying_getter = re.compile(r"[\s,\(\[]varyings\.(\w+)", re.UNICODE)
+re_varying_setter = re.compile(r"\A\s*?varyings\.(\w+)(\.\w+)?\s*?\=")
+builtin_varyings = {"position": "vec4<f32>"}
+
+
+def resolve_varyings(wgsl):
+    """Resolve varyings in the given wgsl:
+    * Detect varyings being used.
+    * Check that these are also set.
+    * Remove assignments of varyings that are not used.
+    * Include the Varyings struct.
+    """
+    assert isinstance(wgsl, str)
+
+    # Split into lines, which is easier to process. Ensure it ends with newline in the end.
+    lines = wgsl.splitlines()
+    while lines and not lines[0].strip():
+        lines.pop(0)
+    while lines and not lines[-1].strip():
+        lines.pop(-1)
+    lines.append("")
+
+    # Prepare dicts that map name to list-of-linenr. And a tupe dict.
+    assigned_varyings = {}
+    used_varyings = {}
+    types = {}  # varying types
+
+    # We try to find the function that first uses the Varyings struct.
+    struct_insert_pos = None
+
+    # Go over all lines to:
+    # - find the lines where a varying is set
+    # - collect the types of these varyings
+    for linenr, line in enumerate(lines):
+        match = re_varying_setter.match(line)
+        if match:
+            # Get parts
+            name = match.group(1)
+            attr = match.group(2)
+            # Handle builtin
+            if name in builtin_varyings:
+                used_varyings[name] = []
+                types[name] = builtin_varyings[name]
+            # Find type
+            type = line[match.end() :].split("(")[0].strip().replace(" ", "")
+            if type not in varying_types:
+                type = ""
+            # Triage
+            if attr:
+                pass  # Not actually a type but an attribute access
+            elif not type:
+                raise TypeError(
+                    f"Varying {name!r} assignment needs an explicit cast (of a correct type), e.g. `varying.{name} = f32(3.0);`:\n{line}"
+                )
+            elif name in types and type != types[name]:
+                raise TypeError(
+                    f"Varying {name!r} assignment does not match expected type {types[name]}:\n{line}"
+                )
+            else:
+                types[name] = type
+            # Store position
+            assigned_varyings.setdefault(name, []).append(linenr)
+
+    # Go over all lines to:
+    # - collect all used varyings
+    # - find where the vertex-shader starts
+    in_vertex_shader = False
+    current_func_linenr = 0
+    for linenr, line in enumerate(lines):
+        line = line.strip()
+        # Detect when we enter a new function
+        if line.startswith("fn "):
+            current_func_linenr = linenr
+            if line.startswith("fn vs_main"):
+                in_vertex_shader = True
+            else:
+                in_vertex_shader = False
+        # Remove comments (shader code has no strings that can contain slashes)
+        line = line.split("//")[0]
+        if "Varyings" in line and struct_insert_pos is None:
+            struct_insert_pos = current_func_linenr
+        # Everything we find here is a match (prepend a space to allow an easier regexp)
+        for match in re_varying_getter.finditer(" " + line):
+            name = match.group(1)
+            this_varying_is_set_on_this_line = linenr in assigned_varyings.get(name, [])
+            if this_varying_is_set_on_this_line:
+                pass
+            elif in_vertex_shader:
+                # If varyings are used in another way than setting, in the vertex shader,
+                # we should either consider them "used", or possibly break the shader if
+                # the used varying is disabled. So let's just not allow it.
+                raise TypeError(
+                    f"Varying {name!r} is read in the vertex shader, but only writing is allowed:\n{line}"
+                )
+            else:
+                used_varyings.setdefault(name, []).append(linenr)
+
+    # Check if all used varyings are assigned
+    for name in used_varyings:
+        if name not in assigned_varyings:
+            line = lines[used_varyings[name][0]]
+            raise TypeError(f"Varying {name!r} is read, but not assigned:\n{line}")
+
+    # Comment-out the varying setter if its unused elsewhere in the shader
+    for name, linenrs in assigned_varyings.items():
+        if name not in used_varyings:
+            for linenr in linenrs:
+                line = lines[linenr]
+                indent = line[: len(line) - len(line.lstrip())]
+                lines[linenr] = indent + "// unused: " + line[len(indent) :]
+                # Deal with multiple-line assignments
+                line_s = line.strip()
+                while not line_s.endswith(";"):
+                    linenr += 1
+                    line_s = lines[linenr].strip()
+                    unexpected = "fn ", "struct ", "var ", "let ", "}"
+                    if line_s.startswith(unexpected) or linenr == len(lines) - 1:
+                        raise TypeError(
+                            f"Varying {name!r} assignment seems to be missing a semicolon:\n{line}"
+                        )
+                    lines[linenr] = indent + "// " + line_s
+
+    # Build and insert the struct
+    if struct_insert_pos is not None:
+        # Maybe we should move up a bit
+        if struct_insert_pos > 0:
+            if lines[struct_insert_pos - 1].lstrip().startswith("[["):
+                struct_insert_pos -= 1
+        # First divide into slot-based and builtins
+        used_varyings = set(used_varyings)
+        used_builtins = used_varyings.intersection(builtin_varyings)
+        used_slots = used_varyings.difference(used_builtins)
+        used_slots = list(sorted(used_slots))
+        # Build struct
+        struct_lines = ["struct Varyings {"]
+        for slotnr, name in enumerate(used_slots):
+            struct_lines.append(f"    [[location({slotnr})]] {name} : {types[name]};")
+        for name in sorted(used_builtins):
+            struct_lines.append(f"    [[builtin({name})]] {name} : {types[name]};")
+        struct_lines.append("};\n")
+        # Apply indentation and insert
+        line = lines[struct_insert_pos]
+        indent = line[: len(line) - len(line.lstrip())]
+        struct_lines = [indent + line for line in struct_lines]
+        lines.insert(struct_insert_pos, "\n".join(struct_lines))
+    else:
+        assert not used_varyings, "woops, did not expect used_varyings here"
+
+    # Return modified code
+    return "\n".join(lines)
+
+
+re_depth_setter = re.compile(r"\A\s*?out\.depth\s*?\=")
+
+
+def resolve_depth_output(wgsl):
+    """When out.depth is set (in the fragment shader), adjust the FragmentOutput
+    to accept depth.
+    """
+    assert isinstance(wgsl, str)
+
+    # Split into lines, which is easier to process. Ensure it ends with newline in the end.
+    lines = wgsl.splitlines()
+    while lines and not lines[0].strip():
+        lines.pop(0)
+    while lines and not lines[-1].strip():
+        lines.pop(-1)
+    lines.append("")
+
+    # Detect whether the depth is set in the shader. We're going to assume
+    # this is in the fragment shader. We check for "out.depth =".
+    # Background: by default the depth is based on the geometry (set
+    # by vertex shader and interpolated). It is possible for a fragment
+    # shader to write the depth instead. If this is done, the GPU cannot
+    # do early depth testing; the fragment shader must be run for the
+    # depth to be known.
+    depth_is_set = False
+    struct_linrnr = -1
+    for linenr, line in enumerate(lines):
+        if line.lstrip().startswith("struct FragmentOutput {"):
+            struct_linrnr = linenr
+        elif re_depth_setter.match(line):
+            depth_is_set = True
+            if struct_linrnr >= 0:
+                break
+
+    if depth_is_set:
+        if struct_linrnr < 0:
+            raise TypeError("FragmentOutput definition not found.")
+        depth_field = "    [[builtin(frag_depth)]] depth : f32;"
+        line = lines[struct_linrnr]
+        indent = line[: len(line) - len(line.lstrip())]
+        lines.insert(struct_linrnr + 1, indent + depth_field)
+
+    return "\n".join(lines)
+
+
 class BaseShader:
     """Base shader object to compose and template shaders using jinja2.
 
@@ -67,7 +272,7 @@ class BaseShader:
     def __setitem__(self, key, value):
         if hasattr(self.__class__, key):
             msg = f"Templating variable {key} causes name clash with class attribute."
-            raise AttributeError(msg)
+            raise KeyError(msg)
         self.kwargs[key] = value
 
     def __getitem__(self, key):
@@ -94,17 +299,25 @@ class BaseShader:
         """Generate the final WGSL with the templating resolved by jinja2.
         Also accepts templating variables as kwargs.
         """
-        code = self.get_code()
-        t = jinja_env.from_string(code)
+        code1 = self.get_code()
+        t = jinja_env.from_string(code1)
 
         variables = self.kwargs.copy()
         variables.update(kwargs)
 
+        err_msg = None
         try:
-            return t.render(**variables)
+            code2 = t.render(**variables)
         except jinja2.UndefinedError as err:
-            msg = f"Canot compose shader: {err.args[0]}"
-        raise ValueError(msg)  # don't raise within handler to avoid recursive tb
+            err_msg = f"Canot compose shader: {err.args[0]}"
+
+        if err_msg:
+            # Don't raise within handler to avoid recursive tb
+            raise ValueError(err_msg)
+        else:
+            code2 = resolve_varyings(code2)
+            code2 = resolve_depth_output(code2)
+            return code2
 
     def define_binding(self, bindgroup, index, binding):
         """Define a uniform, buffer, sampler, or texture. The produced wgsl
@@ -320,30 +533,40 @@ class WorldObjectShader(BaseShader):
     that can be used in all material-specific renderers.
     """
 
-    def __init__(self, wobject, **kwargs):
+    def __init__(self, render_info, **kwargs):
         super().__init__(**kwargs)
 
-        self.kwargs["n_clipping_planes"] = len(wobject.material.clipping_planes)
-        self.kwargs["clipping_mode"] = wobject.material.clipping_mode
+        self["render_pass"] = 1
+        self["n_clipping_planes"] = len(render_info.wobject.material.clipping_planes)
+        self["clipping_mode"] = render_info.wobject.material.clipping_mode
+
+        # Get WGSL for blending.
+        # Defines FragmentOutput2, add_fragment1, add_fragment2, finalize_fragment1, finalize_fragment2.
+        self["blending_code"] = render_info.blender.get_shader_code()
 
     def common_functions(self):
 
-        if not self.kwargs["n_clipping_planes"]:
+        if not self["n_clipping_planes"]:
             clipping_plane_code = """
-            fn apply_clipping_planes(world_pos: vec3<f32>) { }  // zero planes
+            fn check_clipping_planes(world_pos: vec3<f32>) -> bool { return true; }
             """
         else:
             clipping_plane_code = """
-            fn apply_clipping_planes(world_pos: vec3<f32>) {
+            fn check_clipping_planes(world_pos: vec3<f32>) -> bool {
                 var clipped: bool = {{ 'false' if clipping_mode == 'ANY' else 'true' }};
                 for (var i=0; i<{{ n_clipping_planes }}; i=i+1) {
                     let plane = u_material.clipping_planes[i];
                     let plane_clipped = dot( world_pos, plane.xyz ) < plane.w;
                     clipped = clipped {{ '||' if clipping_mode == 'ANY' else '&&' }} plane_clipped;
                 }
-                if (clipped) { discard; }
+                return !clipped;
             }
             """
+        clipping_plane_code += """
+            fn apply_clipping_planes(world_pos: vec3<f32>) {
+                if (!(check_clipping_planes(world_pos))) { discard; }
+            }
+        """
 
         world_pos_code = """
         fn ndc_to_world_pos(ndc_pos: vec4<f32>) -> vec3<f32> {
@@ -353,4 +576,4 @@ class WorldObjectShader(BaseShader):
         }
         """
 
-        return clipping_plane_code + world_pos_code
+        return clipping_plane_code + world_pos_code + self["blending_code"]
diff --git a/pygfx/renderers/wgpu/_update.py b/pygfx/renderers/wgpu/_update.py
new file mode 100644
index 0000000..430b1bf
--- /dev/null
+++ b/pygfx/renderers/wgpu/_update.py
@@ -0,0 +1,184 @@
+"""
+Functions to update resources.
+"""
+
+import wgpu
+
+from ._utils import to_texture_format
+
+
+# Alternative texture formats that we support by padding channels as needed.
+# Maps virtual_format -> (wgpu_format, pad_value, nbytes)
+ALTTEXFORMAT = {
+    "rgb8snorm": ("rgba8snorm", 127, 1),
+    "rgb8unorm": ("rgba8unorm", 255, 1),
+    "rgb8sint": ("rgba8sint", 127, 1),
+    "rgb8uint": ("rgba8uint", 255, 1),
+    "rgb16sint": ("rgba16sint", 32767, 2),
+    "rgb16uint": ("rgba16uint", 65535, 2),
+    "rgb32sint": ("rgba32sint", 2147483647, 4),
+    "rgb32uint": ("rgba32uint", 4294967295, 4),
+    "rgb16float": ("rgba16float", 1, 2),
+    "rgb32float": ("rgba32float", 1, 4),
+}
+
+
+def update_resource(device, resource, kind):
+    if kind == "buffer":
+        return update_buffer(device, resource)
+    elif kind == "texture_view":
+        return update_texture_view(device, resource)
+    elif kind == "texture":
+        return update_texture(device, resource)
+    elif kind == "sampler":
+        return update_sampler(device, resource)
+    else:
+        raise ValueError(f"Invalid resource kind: {kind}")
+
+
+def update_buffer(device, resource):
+    buffer = getattr(resource, "_wgpu_buffer", (-1, None))[1]
+
+    # todo: dispose an old buffer? / reuse an old buffer?
+
+    pending_uploads = resource._pending_uploads
+    resource._pending_uploads = []
+    bytes_per_item = resource.nbytes // resource.nitems
+
+    # Create buffer if needed
+    if buffer is None or buffer.size != resource.nbytes:
+        resource._wgpu_usage |= wgpu.BufferUsage.COPY_DST
+        buffer = device.create_buffer(size=resource.nbytes, usage=resource._wgpu_usage)
+
+    queue = device.queue
+    encoder = device.create_command_encoder()
+
+    # Upload any pending data
+    for offset, size in pending_uploads:
+        subdata = resource._get_subdata(offset, size)
+        # A: map the buffer, writes to it, then unmaps. But we don't offer a mapping API in wgpu-py
+        # B: roll data in new buffer, copy from there to existing buffer
+        tmp_buffer = device.create_buffer_with_data(
+            data=subdata,
+            usage=wgpu.BufferUsage.COPY_SRC,
+        )
+        boffset, bsize = bytes_per_item * offset, bytes_per_item * size
+        encoder.copy_buffer_to_buffer(tmp_buffer, 0, buffer, boffset, bsize)
+        # C: using queue. This may be sugar for B, but it may also be optimized
+        # Unfortunately, this seems to crash the device :/
+        # queue.write_buffer(buffer, bytes_per_item * offset, subdata, 0, subdata.nbytes)
+        # D: A staging buffer/belt https://github.com/gfx-rs/wgpu-rs/blob/master/src/util/belt.rs
+        # todo: look into staging buffers?
+
+    queue.submit([encoder.finish()])
+    resource._wgpu_buffer = resource.rev, buffer
+
+
+def update_texture_view(device, resource):
+    if resource._is_default_view:
+        texture_view = resource.texture._wgpu_texture[1].create_view()
+    else:
+        dim = resource._view_dim
+        assert resource._mip_range.step == 1
+        assert resource._layer_range.step == 1
+        fmt = to_texture_format(resource.format)
+        fmt = ALTTEXFORMAT.get(fmt, [fmt])[0]
+        texture_view = resource.texture._wgpu_texture[1].create_view(
+            format=fmt,
+            dimension=f"{dim}d" if isinstance(dim, int) else dim,
+            aspect=resource._aspect,
+            base_mip_level=resource._mip_range.start,
+            mip_level_count=len(resource._mip_range),
+            base_array_layer=resource._layer_range.start,
+            array_layer_count=len(resource._layer_range),
+        )
+    resource._wgpu_texture_view = resource.rev, texture_view
+
+
+def update_texture(device, resource):
+
+    texture = getattr(resource, "_wgpu_texture", (-1, None))[1]
+    pending_uploads = resource._pending_uploads
+    resource._pending_uploads = []
+
+    fmt = to_texture_format(resource.format)
+    pixel_padding = None
+    if fmt in ALTTEXFORMAT:
+        fmt, pixel_padding, extra_bytes = ALTTEXFORMAT[fmt]
+
+    # Create texture if needed
+    if texture is None:  # todo: or needs to be replaced (e.g. resized)
+        resource._wgpu_usage |= wgpu.TextureUsage.COPY_DST
+        texture = device.create_texture(
+            size=resource.size,
+            usage=resource._wgpu_usage,
+            dimension=f"{resource.dim}d",
+            format=getattr(wgpu.TextureFormat, fmt),
+            mip_level_count=1,
+            sample_count=1,  # msaa?
+        )  # todo: let resource specify mip_level_count and sample_count
+
+    bytes_per_pixel = resource.nbytes // (
+        resource.size[0] * resource.size[1] * resource.size[2]
+    )
+    if pixel_padding is not None:
+        bytes_per_pixel += extra_bytes
+
+    queue = device.queue
+    encoder = device.create_command_encoder()
+
+    # Upload any pending data
+    for offset, size in pending_uploads:
+        subdata = resource._get_subdata(offset, size, pixel_padding)
+        # B: using a temp buffer
+        # tmp_buffer = device.create_buffer_with_data(data=subdata,
+        #     usage=wgpu.BufferUsage.COPY_SRC,
+        # )
+        # encoder.copy_buffer_to_texture(
+        #     {
+        #         "buffer": tmp_buffer,
+        #         "offset": 0,
+        #         "bytes_per_row": size[0] * bytes_per_pixel,  # multiple of 256
+        #         "rows_per_image": size[1],
+        #     },
+        #     {
+        #         "texture": texture,
+        #         "mip_level": 0,
+        #         "origin": offset,
+        #     },
+        #     copy_size=size,
+        # )
+        # C: using the queue, which may be doing B, but may also be optimized,
+        #    and the bytes_per_row limitation does not apply here
+        queue.write_texture(
+            {"texture": texture, "origin": offset, "mip_level": 0},
+            subdata,
+            {"bytes_per_row": size[0] * bytes_per_pixel, "rows_per_image": size[1]},
+            size,
+        )
+
+    queue.submit([encoder.finish()])
+    resource._wgpu_texture = resource.rev, texture
+
+
+def update_sampler(device, resource):
+    # A sampler's info (and raw object) are stored on a TextureView
+    amodes = resource._address_mode.replace(",", " ").split() or ["clamp"]
+    while len(amodes) < 3:
+        amodes.append(amodes[-1])
+    filters = resource._filter.replace(",", " ").split() or ["nearest"]
+    while len(filters) < 3:
+        filters.append(filters[-1])
+    ammap = {"clamp": "clamp-to-edge", "mirror": "mirror-repeat"}
+    sampler = device.create_sampler(
+        address_mode_u=ammap.get(amodes[0], amodes[0]),
+        address_mode_v=ammap.get(amodes[1], amodes[1]),
+        address_mode_w=ammap.get(amodes[2], amodes[2]),
+        mag_filter=filters[0],
+        min_filter=filters[1],
+        mipmap_filter=filters[2],
+        # lod_min_clamp -> use default 0
+        # lod_max_clamp -> use default inf
+        # compare -> only not-None for comparison samplers!
+    )
+    resource._wgpu_sampler = resource.rev, sampler
diff --git a/pygfx/renderers/wgpu/_conv.py b/pygfx/renderers/wgpu/_utils.py
similarity index 85%
rename from pygfx/renderers/wgpu/_conv.py
rename to pygfx/renderers/wgpu/_utils.py
index b75fa10..ca7b63f 100644
--- a/pygfx/renderers/wgpu/_conv.py
+++ b/pygfx/renderers/wgpu/_utils.py
@@ -1,5 +1,20 @@
 import wgpu
 
+from .. import RenderFunctionRegistry
+
+
+registry = RenderFunctionRegistry()
+
+
+def register_wgpu_render_function(wobject_cls, material_cls):
+    """Decorator to register a WGPU render function."""
+
+    def _register_wgpu_renderer(f):
+        registry.register(wobject_cls, material_cls, f)
+        return f
+
+    return _register_wgpu_renderer
+
 
 def to_vertex_format(format):
     """Convert pygfx' own format to the wgpu format."""
diff --git a/pygfx/renderers/wgpu/_wgpurenderer.py b/pygfx/renderers/wgpu/_wgpurenderer.py
deleted file mode 100644
index f6966f2..0000000
--- a/pygfx/renderers/wgpu/_wgpurenderer.py
+++ /dev/null
@@ -1,1294 +0,0 @@
-import time
-import weakref
-
-import numpy as np
-import wgpu.backends.rs
-
-from .. import Renderer, RenderFunctionRegistry
-from ...linalg import Matrix4, Vector3
-from ...objects import WorldObject
-from ...cameras import Camera
-from ...resources import Buffer, Texture, TextureView
-from ...utils import array_from_shadertype
-
-from ._renderutils import RenderTexture, RenderFlusher
-from ._conv import to_vertex_format, to_texture_format
-
-
-# Definition uniform struct with standard info related to transforms,
-# provided to each shader as uniform at slot 0.
-# todo: a combined transform would be nice too, for performance
-# todo: same for ndc_to_world transform (combined inv transforms)
-stdinfo_uniform_type = dict(
-    cam_transform="4x4xf4",
-    cam_transform_inv="4x4xf4",
-    projection_transform="4x4xf4",
-    projection_transform_inv="4x4xf4",
-    physical_size="2xf4",
-    logical_size="2xf4",
-    flipped_winding="i4",  # A bool, really
-)
-
-
-registry = RenderFunctionRegistry()
-
-# Alternative texture formats that we support by padding channels as needed.
-# Maps virtual_format -> (wgpu_format, pad_value, nbytes)
-ALTTEXFORMAT = {
-    "rgb8snorm": ("rgba8snorm", 127, 1),
-    "rgb8unorm": ("rgba8unorm", 255, 1),
-    "rgb8sint": ("rgba8sint", 127, 1),
-    "rgb8uint": ("rgba8uint", 255, 1),
-    "rgb16sint": ("rgba16sint", 32767, 2),
-    "rgb16uint": ("rgba16uint", 65535, 2),
-    "rgb32sint": ("rgba32sint", 2147483647, 4),
-    "rgb32uint": ("rgba32uint", 4294967295, 4),
-    "rgb16float": ("rgba16float", 1, 2),
-    "rgb32float": ("rgba32float", 1, 4),
-}
-
-
-def register_wgpu_render_function(wobject_cls, material_cls):
-    """Decorator to register a WGPU render function."""
-
-    def _register_wgpu_renderer(f):
-        registry.register(wobject_cls, material_cls, f)
-        return f
-
-    return _register_wgpu_renderer
-
-
-def get_size_from_render_target(target):
-    """Get physical and logical size from a render target."""
-    if isinstance(target, wgpu.gui.WgpuCanvasBase):
-        physical_size = target.get_physical_size()
-        logical_size = target.get_logical_size()
-    elif isinstance(target, Texture):
-        physical_size = target.size[:2]
-        logical_size = physical_size
-    elif isinstance(target, TextureView):
-        physical_size = target.texture.size[:2]
-        logical_size = physical_size
-    else:
-        raise TypeError(f"Unexpected render target {target.__class__.__name__}")
-    return physical_size, logical_size
-
-
-class RenderInfo:
-    """The type of object passed to each wgpu render function together
-    with the world object. Contains stdinfo buffer for now. In time
-    will probably also include lights etc.
-    """
-
-    def __init__(self, *, stdinfo_uniform):
-        self.stdinfo_uniform = stdinfo_uniform
-
-
-class SharedData:
-    """An object to store global data to share between multiple wgpu renderers.
-
-    Since renderers don't render simultaneously, they can share certain
-    resources. This safes memory, but more importantly, resources that
-    get used in wobject pipelines should be shared to avoid having to
-    constantly recompose the pipelines of wobjects that are rendered by
-    multiple renderers.
-    """
-
-    def __init__(self, canvas):
-
-        # Create adapter and device objects - there should be just one per canvas.
-        # Having a global device provides the benefit that we can draw any object
-        # anywhere.
-        # We do pass the canvas to request_adapter(), so we get an adapter that is
-        # at least compatible with the first canvas that a renderer is create for.
-        self.adapter = wgpu.request_adapter(
-            canvas=canvas, power_preference="high-performance"
-        )
-        self.device = self.adapter.request_device(
-            required_features=[], required_limits={}
-        )
-
-        # Create a uniform buffer for std info
-        self.stdinfo_buffer = Buffer(array_from_shadertype(stdinfo_uniform_type))
-        self.stdinfo_buffer._wgpu_usage |= wgpu.BufferUsage.UNIFORM
-
-
-class WgpuRenderer(Renderer):
-    """Object used to render scenes using wgpu.
-
-    The purpose of a renderer is to render (i.e. draw) a scene to a
-    canvas or texture. It also provides picking, defines the
-    anti-aliasing parameters, and any post processing effects.
-
-    It provides a ``.render()`` method that can be called one or more
-    times to render scene. This creates a visual representation that
-    is stored internally, and is finally rendered into its render target
-    (the canvas or texture).
-                                  __________
-                                 | renderer |
-        [scenes] -- render() --> |  state   | -- flush() --> [target]
-                                 |__________|
-
-    The internal visual representation includes things like a depth
-    buffer and is typically at a higher resolution to reduce aliasing
-    effects. Further, the representation may in the future accomodate
-    for proper blending of semitransparent objects.
-
-    The flush-step renders the internal representation into the target
-    texture or canvas, applying anti-aliasing. In the future this is
-    also where fog is applied, as well as any custom post-processing
-    effects.
-
-    Parameters:
-        target (WgpuCanvas or Texture): The target to render to, and what
-            determines the size of the render buffer.
-        pixel_ratio (float, optional): How large the physical size of the render
-            buffer is in relation to the target's physical size, for antialiasing.
-            See the corresponding property for details.
-        show_fps (bool): Whether to display the frames per second. Beware that
-            depending on the GUI toolkit, the canvas may impose a frame rate limit.
-    """
-
-    _shared = None
-
-    def __init__(self, target, *, pixel_ratio=None, show_fps=False):
-
-        # Check and normalize inputs
-        if not isinstance(target, (Texture, TextureView, wgpu.gui.WgpuCanvasBase)):
-            raise TypeError(
-                f"Render target must be a canvas or texture (view), not a {target.__class__.__name__}"
-            )
-        self._target = target
-
-        # Process other inputs
-        self.pixel_ratio = pixel_ratio
-        self._show_fps = bool(show_fps)
-
-        # Make sure we have a shared object (the first renderer create it)
-        canvas = target if isinstance(target, wgpu.gui.WgpuCanvasBase) else None
-        if WgpuRenderer._shared is None:
-            WgpuRenderer._shared = SharedData(canvas)
-
-        # Init cache of shaders
-        self._shader_cache = {}
-
-        # Init counter to auto-clear
-        self._renders_since_last_flush = 0
-
-        # Get target format
-        if isinstance(target, wgpu.gui.WgpuCanvasBase):
-            self._canvas_context = self._target.get_context()
-            self._target_tex_format = self._canvas_context.get_preferred_format(
-                self._shared.adapter
-            )
-            # Also configure the canvas
-            self._canvas_context.configure(
-                device=self._shared.device,
-                format=self._target_tex_format,
-                usage=wgpu.TextureUsage.RENDER_ATTACHMENT,
-            )
-        else:
-            self._target_tex_format = self._target.format
-            # Also enable the texture for render and display usage
-            self._target._wgpu_usage |= wgpu.TextureUsage.RENDER_ATTACHMENT
-            self._target._wgpu_usage |= wgpu.TextureUsage.TEXTURE_BINDING
-
-        # Prepare render targets. These are placeholders to set during
-        # each renderpass, intended to keep together the texture-view
-        # object, its size, and its format.
-        self._render_texture = RenderTexture(wgpu.TextureFormat.rgba8unorm)
-        self._depth_texture = RenderTexture(wgpu.TextureFormat.depth32float)
-        # The pick texture has 4 channels, object id, and then 3 more, e.g.
-        # the instance nr, vertex nr and weights.
-        self._pick_texture = RenderTexture(wgpu.TextureFormat.rgba32sint)
-
-        # Prepare object that performs the final render step into a texture
-        self._flusher = RenderFlusher(self._shared.device)
-
-        # Prepare other properties
-        self._msaa = 1  # todo: cannot set sample_count of render_pass yet
-
-        # Initialize a small buffer to read pixel info into
-        # Make it 256 bytes just in case (for bytes_per_row)
-        self._pixel_info_buffer = self._shared.device.create_buffer(
-            size=256,
-            usage=wgpu.BufferUsage.COPY_DST | wgpu.BufferUsage.MAP_READ,
-        )
-
-        # Keep track of object ids
-        self._pick_map = weakref.WeakValueDictionary()
-
-    @property
-    def device(self):
-        """A reference to the used wgpu device."""
-        return self._shared.device
-
-    @property
-    def target(self):
-        """The render target. Can be a canvas, texture or texture view."""
-        return self._target
-
-    @property
-    def pixel_ratio(self):
-        """The ratio between the number of internal pixels versus the logical pixels on the canvas.
-
-        This can be used to configure the size of the render texture
-        relative to the canvas' logical size. By default (value is None) the
-        used pixel ratio follows the screens pixel ratio on high-res
-        displays, and is 2 otherwise.
-
-        If the used pixel ratio causes the render texture to be larger
-        than the physical size of the canvas, SSAA is applied, resulting
-        in a smoother final image with less jagged edges. Alternatively,
-        this value can be set to e.g. 0.5 to lower* the resolution (e.g.
-        for performance during interaction).
-        """
-        return self._pixel_ratio
-
-    @pixel_ratio.setter
-    def pixel_ratio(self, value):
-        if value is None:
-            self._pixel_ratio = None
-        elif isinstance(value, (int, float)):
-            self._pixel_ratio = None if value <= 0 else float(value)
-        else:
-            raise TypeError(
-                f"Rendered.pixel_ratio expected None or number, not {value}"
-            )
-
-    def render(
-        self,
-        scene: WorldObject,
-        camera: Camera,
-        *,
-        viewport=None,
-        clear_color=None,
-        clear_depth=None,
-        flush=True,
-    ):
-        """Render a scene with the specified camera as the viewpoint.
-
-        Parameters:
-            scene (WorldObject): The scene to render, a WorldObject that
-                optionally has child objects.
-            camera (Camera): The camera object to use, which defines the
-                viewpoint and view transform.
-            viewport (tuple, optional): The rectangular region to draw into,
-                expressed in logical pixels.
-            clear_color (bool, optional): Whether to clear the color buffer
-                before rendering. By default this is True on the first
-                call to ``render()`` after a flush, and False otherwise.
-            clear_depth (bool, optional): Whether to clear the depth buffer
-                before rendering. By default this is True on the first
-                call to ``render()`` after a flush, and False otherwise.
-            flush (bool, optional): Whether to flush the rendered result into
-                the target (texture or canvas). Default True.
-        """
-        device = self.device
-
-        now = time.perf_counter()  # noqa
-        if self._show_fps:
-            if not hasattr(self, "_fps"):
-                self._fps = now, now, 1
-            elif now > self._fps[0] + 1:
-                print(f"FPS: {self._fps[2]/(now - self._fps[0]):0.1f}")
-                self._fps = now, now, 1
-            else:
-                self._fps = self._fps[0], now, self._fps[2] + 1
-
-        # Define whether to clear color and/or depth
-        if clear_color is None:
-            clear_color = self._renders_since_last_flush == 0
-        clear_color = bool(clear_color)
-        if clear_depth is None:
-            clear_depth = self._renders_since_last_flush == 0
-        clear_depth = bool(clear_depth)
-        self._renders_since_last_flush += 1
-
-        # todo: also note that the fragment shader is (should be) optional
-        #      (e.g. depth only passes like shadow mapping or z prepass)
-
-        # Get logical size (as two floats). This size is constant throughout
-        # all post-processing render passes.
-        target_size, logical_size = get_size_from_render_target(self._target)
-        if not all(x > 0 for x in logical_size):
-            return
-
-        # Determine the physical size of the render texture
-        target_pixel_ratio = target_size[0] / logical_size[0]
-        if self._pixel_ratio:
-            pixel_ratio = self._pixel_ratio
-        else:
-            pixel_ratio = target_pixel_ratio
-            if pixel_ratio <= 1:
-                pixel_ratio = 2.0  # use 2 on non-hidpi displays
-
-        # Determine the physical size of the first and last render pass
-        framebuffer_size = tuple(max(1, int(pixel_ratio * x)) for x in logical_size)
-
-        # Set the size of the textures (is a no-op if the size does not change)
-        self._render_texture.ensure_size(device, framebuffer_size + (1,))
-        self._depth_texture.ensure_size(device, framebuffer_size + (1,))
-        self._pick_texture.ensure_size(device, framebuffer_size + (1,))
-
-        # Get viewport in physical pixels
-        if not viewport:
-            scene_logical_size = logical_size
-            scene_physical_size = framebuffer_size
-            physical_viewport = 0, 0, framebuffer_size[0], framebuffer_size[1], 0, 1
-        elif len(viewport) == 4:
-            scene_logical_size = viewport[2], viewport[3]
-            physical_viewport = [int(i * pixel_ratio + 0.4999) for i in viewport]
-            physical_viewport = tuple(physical_viewport) + (0, 1)
-            scene_physical_size = physical_viewport[2], physical_viewport[3]
-        else:
-            raise ValueError("The viewport must be None or 4 elements (x, y, w, h).")
-
-        # Ensure that matrices are up-to-date
-        scene.update_matrix_world()
-        camera.set_view_size(*scene_logical_size)
-        camera.update_matrix_world()  # camera may not be a member of the scene
-        camera.update_projection_matrix()
-
-        # Get the list of objects to render (visible and having a material)
-        q = self.get_render_list(scene, camera)
-        for wobject in q:
-            self._pick_map[wobject.id] = wobject
-
-        # Update stdinfo uniform buffer object that we'll use during this render call
-        self._update_stdinfo_buffer(camera, scene_physical_size, scene_logical_size)
-
-        # Ensure each wobject has pipeline info
-        for wobject in q:
-            self._ensure_up_to_date(wobject)
-
-        # Filter out objects that we cannot render
-        q = [wobject for wobject in q if wobject._wgpu_pipeline_objects is not None]
-
-        # Render the scene graph (to the first texture)
-        command_encoder = device.create_command_encoder()
-        self._render_recording(
-            command_encoder, q, physical_viewport, clear_color, clear_depth
-        )
-        command_buffers = [command_encoder.finish()]
-        device.queue.submit(command_buffers)
-
-        # Flush to target
-        if flush:
-            self.flush()
-
-    def flush(self):
-        """Render the result into the target texture view. This method is
-        called automatically unless you use ``.render(..., flush=False)``.
-        """
-
-        # Note: we could, in theory, allow specifying a custom target here.
-
-        if isinstance(self._target, wgpu.gui.WgpuCanvasBase):
-            raw_texture_view = self._canvas_context.get_current_texture()
-        else:
-            if isinstance(self._target, Texture):
-                texture_view = self._target.get_view()
-            elif isinstance(self._target, TextureView):
-                texture_view = self._target
-            self._update_texture(texture_view.texture)
-            self._update_texture_view(texture_view)
-            raw_texture_view = texture_view._wgpu_texture_view[1]
-
-        self._flusher.render(
-            self._render_texture.texture_view,
-            None,
-            raw_texture_view,
-            self._target_tex_format,
-        )
-
-        # Reset counter (so we can auto-clear the first next draw)
-        self._renders_since_last_flush = 0
-
-    def _render_recording(
-        self, command_encoder, q, physical_viewport, clear_color, clear_depth
-    ):
-
-        # You might think that this is slow for large number of world
-        # object. But it is actually pretty good. It does iterate over
-        # all world objects, and over stuff in each object. But that's
-        # it, really.
-        # todo: we may be able to speed this up with render bundles though
-
-        # ----- compute pipelines
-
-        compute_pass = command_encoder.begin_compute_pass()
-
-        for wobject in q:
-            wgpu_data = wobject._wgpu_pipeline_objects
-            for pinfo in wgpu_data["compute_pipelines"]:
-                compute_pass.set_pipeline(pinfo["pipeline"])
-                for bind_group_id, bind_group in enumerate(pinfo["bind_groups"]):
-                    compute_pass.set_bind_group(
-                        bind_group_id, bind_group, [], 0, 999999
-                    )
-                compute_pass.dispatch(*pinfo["index_args"])
-
-        compute_pass.end_pass()
-
-        # ----- render pipelines rendering to the default target
-
-        if clear_color:
-            color_load_value = 0, 0, 0, 0
-            pick_load_value = 0, 0, 0, 0
-        else:
-            color_load_value = wgpu.LoadOp.load
-            pick_load_value = wgpu.LoadOp.load
-        if clear_depth:
-            # depth is 0..1, make initial value as high as we can
-            depth_load_value = 1.0
-        else:
-            depth_load_value = wgpu.LoadOp.load
-
-        assert self._render_texture.texture_view
-        assert self._depth_texture.texture_view
-        assert self._pick_texture.texture_view
-        render_pass = command_encoder.begin_render_pass(
-            color_attachments=[
-                {
-                    "view": self._render_texture.texture_view,
-                    "resolve_target": None,
-                    "load_value": color_load_value,
-                    "store_op": wgpu.StoreOp.store,
-                },
-                {
-                    "view": self._pick_texture.texture_view,
-                    "resolve_target": None,
-                    "load_value": pick_load_value,
-                    "store_op": wgpu.StoreOp.store,
-                },
-            ],
-            depth_stencil_attachment={
-                "view": self._depth_texture.texture_view,
-                "depth_load_value": depth_load_value,
-                "depth_store_op": wgpu.StoreOp.store,
-                "stencil_load_value": wgpu.LoadOp.load,
-                "stencil_store_op": wgpu.StoreOp.store,
-            },
-            occlusion_query_set=None,
-        )
-        render_pass.set_viewport(*physical_viewport)
-
-        for wobject in q:
-            wgpu_data = wobject._wgpu_pipeline_objects
-            for pinfo in wgpu_data["render_pipelines"]:
-                render_pass.set_pipeline(pinfo["pipeline"])
-                for slot, vbuffer in pinfo["vertex_buffers"].items():
-                    render_pass.set_vertex_buffer(
-                        slot,
-                        vbuffer._wgpu_buffer[1],
-                        vbuffer.vertex_byte_range[0],
-                        vbuffer.vertex_byte_range[1],
-                    )
-                for bind_group_id, bind_group in enumerate(pinfo["bind_groups"]):
-                    render_pass.set_bind_group(bind_group_id, bind_group, [], 0, 99)
-                # Draw with or without index buffer
-                if pinfo["index_buffer"] is not None:
-                    ibuffer = pinfo["index_buffer"]
-                    render_pass.set_index_buffer(ibuffer, 0, ibuffer.size)
-                    render_pass.draw_indexed(*pinfo["index_args"])
-                else:
-                    render_pass.draw(*pinfo["index_args"])
-
-        render_pass.end_pass()
-
-    def _update_stdinfo_buffer(self, camera, physical_size, logical_size):
-        # Update the stdinfo buffer's data
-        stdinfo_data = self._shared.stdinfo_buffer.data
-        stdinfo_data["cam_transform"].flat = camera.matrix_world_inverse.elements
-        stdinfo_data["cam_transform_inv"].flat = camera.matrix_world.elements
-        stdinfo_data["projection_transform"].flat = camera.projection_matrix.elements
-        stdinfo_data[
-            "projection_transform_inv"
-        ].flat = camera.projection_matrix_inverse.elements
-        # stdinfo_data["ndc_to_world"].flat = np.linalg.inv(stdinfo_data["cam_transform"] @ stdinfo_data["projection_transform"])
-        stdinfo_data["physical_size"] = physical_size
-        stdinfo_data["logical_size"] = logical_size
-        stdinfo_data["flipped_winding"] = camera.flips_winding
-        # Upload to GPU
-        self._shared.stdinfo_buffer.update_range(0, 1)
-        self._update_buffer(self._shared.stdinfo_buffer)
-
-    def get_render_list(self, scene: WorldObject, camera: Camera):
-        """Given a scene object, get a flat list of objects to render."""
-
-        # Collect items
-        def visit(wobject):
-            nonlocal q
-            if wobject.material is not None:
-                q.append(wobject)
-
-        q = []
-        scene.traverse(visit, True)
-
-        # Next, sort them from back-to-front
-        def sort_func(wobject: WorldObject):
-            z = (
-                Vector3()
-                .set_from_matrix_position(wobject.matrix_world)
-                .apply_matrix4(proj_screen_matrix)
-                .z
-            )
-            return wobject.render_order, z
-
-        proj_screen_matrix = Matrix4().multiply_matrices(
-            camera.projection_matrix, camera.matrix_world_inverse
-        )
-        q.sort(key=sort_func)
-        return q
-
-    def _ensure_up_to_date(self, wobject, force=False):
-        """Update the GPU objects associated with the given wobject. Returns
-        quickly if no changes are needed.
-        """
-
-        # Do we need to create the pipeline infos (from the renderfunc for this wobject)?
-        if force or wobject.rev > getattr(wobject, "_wgpu_rev", 0):
-            wobject._wgpu_rev = wobject.rev
-            wobject._wgpu_pipeline_infos = None
-            wobject._wgpu_pipeline_objects = None  # Invalidate
-            wobject._wgpu_pipeline_infos = self._create_pipeline_infos(wobject)
-            wobject._wgpu_pipeline_res = self._collect_pipeline_resources(wobject)
-
-        # Early exit?
-        if not wobject._wgpu_pipeline_infos:
-            return
-
-        # Check if we need to update any resources. The number of
-        # resources should typically be small. We could implement a
-        # hook in the resource's rev setter so we only have to check
-        # one flag ... but let's not optimize prematurely.
-        for kind, resource in wobject._wgpu_pipeline_res:
-            our_version = getattr(resource, "_wgpu_" + kind, (-1, None))[0]
-            if resource.rev > our_version:
-                update_func = getattr(self, "_update_" + kind)
-                update_func(resource)
-                # one of self._update_buffer self._update_texture, self._update_texture_view, self._update_sampler
-
-        # Create gpu objects?
-        if wobject._wgpu_pipeline_objects is None:
-            wobject._wgpu_pipeline_objects = self._create_pipeline_objects(wobject)
-
-    def _create_pipeline_infos(self, wobject):
-        """Use the render function for this wobject and material,
-        and return a list of dicts representing pipelines in an abstract way.
-        These dicts can then be turned into actual pipeline objects.
-        """
-
-        # Get render function for this world object,
-        # and use it to get a high-level description of pipelines.
-        renderfunc = registry.get_render_function(wobject)
-        if renderfunc is None:
-            raise ValueError(
-                f"Could not get a render function for {wobject.__class__.__name__} "
-                f"with {wobject.material.__class__.__name__}"
-            )
-
-        # Prepare info for the render function
-        render_info = RenderInfo(
-            stdinfo_uniform=self._shared.stdinfo_buffer,
-        )
-
-        # Call render function
-        pipeline_infos = renderfunc(wobject, render_info)
-        if not pipeline_infos:
-            pipeline_infos = None
-        else:
-            assert isinstance(pipeline_infos, list)
-
-        return pipeline_infos
-
-    def _collect_pipeline_resources(self, wobject):
-
-        pipeline_infos = wobject._wgpu_pipeline_infos or []
-
-        pipeline_resources = []  # List, because order matters
-
-        # Collect list of resources. This way we can easily iterate
-        # over dependent resource on each render call. We also set the
-        # usage flag of buffers and textures specified in the pipeline.
-        for pipeline_info in pipeline_infos:
-            assert isinstance(pipeline_info, dict)
-            buffer = pipeline_info.get("index_buffer", None)
-            if buffer is not None:
-                buffer._wgpu_usage |= wgpu.BufferUsage.INDEX
-                pipeline_resources.append(("buffer", buffer))
-            for buffer in pipeline_info.get("vertex_buffers", {}).values():
-                buffer._wgpu_usage |= wgpu.BufferUsage.VERTEX
-                pipeline_resources.append(("buffer", buffer))
-            for key in pipeline_info.keys():
-                if key.startswith("bindings"):
-                    resources = pipeline_info[key]
-                    if isinstance(resources, dict):
-                        resources = resources.values()
-                    for binding in resources:
-                        resource = binding.resource
-                        if binding.type.startswith("buffer/"):
-                            assert isinstance(resource, Buffer)
-                            pipeline_resources.append(("buffer", resource))
-                            if "uniform" in binding.type:
-                                resource._wgpu_usage |= wgpu.BufferUsage.UNIFORM
-                            elif "storage" in binding.type:
-                                resource._wgpu_usage |= wgpu.BufferUsage.STORAGE
-                        elif binding.type.startswith("sampler/"):
-                            assert isinstance(resource, TextureView)
-                            pipeline_resources.append(("sampler", resource))
-                        elif binding.type.startswith("texture/"):
-                            assert isinstance(resource, TextureView)
-                            resource.texture._wgpu_usage |= (
-                                wgpu.TextureUsage.TEXTURE_BINDING
-                            )
-                            pipeline_resources.append(("texture", resource.texture))
-                            pipeline_resources.append(("texture_view", resource))
-                        elif binding.type.startswith("storage_texture/"):
-                            assert isinstance(resource, TextureView)
-                            resource.texture._wgpu_usage |= (
-                                wgpu.TextureUsage.STORAGE_BINDING
-                            )
-                            pipeline_resources.append(("texture", resource.texture))
-                            pipeline_resources.append(("texture_view", resource))
-                        else:
-                            raise RuntimeError(
-                                f"Unknown resource binding {binding.name} of type {binding.type}"
-                            )
-
-        return pipeline_resources
-
-    def _create_pipeline_objects(self, wobject):
-        """Generate wgpu pipeline objects from the list of pipeline info dicts."""
-
-        # Prepare the three kinds of pipelines that we can get
-        compute_pipelines = []
-        render_pipelines = []
-        alt_render_pipelines = []
-
-        # Process each pipeline info object, converting each to a more concrete dict
-        for pipeline_info in wobject._wgpu_pipeline_infos:
-            if "vertex_shader" in pipeline_info and "fragment_shader" in pipeline_info:
-                pipeline = self._compose_render_pipeline(wobject, pipeline_info)
-                if pipeline_info.get("target", None) is None:
-                    render_pipelines.append(pipeline)
-                else:
-                    raise NotImplementedError("Alternative render pipelines")
-                    alt_render_pipelines.append(pipeline)
-            elif "compute_shader" in pipeline_info:
-                compute_pipelines.append(
-                    self._compose_compute_pipeline(wobject, pipeline_info)
-                )
-            else:
-                raise ValueError(
-                    "Did not find compute_shader nor vertex_shader+fragment_shader in pipeline info."
-                )
-
-        return {
-            "compute_pipelines": compute_pipelines,
-            "render_pipelines": render_pipelines,
-            "alt_render_pipelines": alt_render_pipelines,
-        }
-
-    def _compose_compute_pipeline(self, wobject, pipeline_info):
-        """Given a high-level compute pipeline description, creates a
-        lower-level representation that can be consumed by wgpu.
-        """
-
-        # todo: cache the pipeline with the shader (and entrypoint) as a hash
-
-        device = self.device
-
-        # Convert indices to args for the compute_pass.dispatch() call
-        indices = pipeline_info["indices"]
-        if not (
-            isinstance(indices, tuple)
-            and len(indices) == 3
-            and all(isinstance(i, int) for i in indices)
-        ):
-            raise RuntimeError(
-                f"Compute indices must be 3-tuple of ints, not {indices}."
-            )
-        index_args = indices
-
-        # Get bind groups and pipeline layout from the buffers in pipeline_info.
-        # This also makes sure the buffers and textures are up-to-date.
-        bind_groups, pipeline_layout = self._get_bind_groups(pipeline_info)
-
-        # Compile shader and create pipeline object
-        cshader = pipeline_info["compute_shader"]
-        cs_module = device.create_shader_module(code=cshader)
-        compute_pipeline = device.create_compute_pipeline(
-            layout=pipeline_layout,
-            compute={"module": cs_module, "entry_point": "main"},
-        )
-
-        return {
-            "pipeline": compute_pipeline,  # wgpu object
-            "index_args": index_args,  # tuple
-            "bind_groups": bind_groups,  # list of wgpu bind_group objects
-        }
-
-    def _compose_render_pipeline(self, wobject, pipeline_info):
-        """Given a high-level render pipeline description, creates a
-        lower-level representation that can be consumed by wgpu.
-        """
-
-        # todo: cache the pipeline with a lot of things as the hash
-        # todo: cache vertex descriptors
-
-        device = self.device
-
-        # If an index buffer is present, update it, and get index_format.
-        wgpu_index_buffer = None
-        index_format = wgpu.IndexFormat.uint32
-        index_buffer = pipeline_info.get("index_buffer", None)
-        if index_buffer is not None:
-            wgpu_index_buffer = index_buffer._wgpu_buffer[1]
-            index_format = to_vertex_format(index_buffer.format)
-            index_format = index_format.split("x")[0].replace("s", "u")
-
-        # Convert and check high-level indices. Indices represent a range
-        # of index id's, or define what indices in the index buffer are used.
-        indices = pipeline_info.get("indices", None)
-        if indices is None:
-            if index_buffer is None:
-                raise RuntimeError("Need indices or index_buffer ")
-            indices = range(index_buffer.data.size)
-        # Convert to 2-element tuple (vertex, instance)
-        if not isinstance(indices, tuple):
-            indices = (indices,)
-        if len(indices) == 1:
-            indices = indices + (1,)  # add instancing index
-        if len(indices) != 2:
-            raise RuntimeError("Render pipeline indices must be a 2-element tuple.")
-
-        # Convert indices to args for the render_pass.draw() or draw_indexed()
-        # draw(count_vertex, count_instance, first_vertex, first_instance)
-        # draw_indexed(count_v, count_i, first_vertex, base_vertex, first_instance)
-        index_args = [0, 0, 0, 0]
-        for i, index in enumerate(indices):
-            if isinstance(index, int):
-                index_args[i] = index
-            elif isinstance(index, range):
-                assert index.step == 1
-                index_args[i] = index.stop - index.start
-                index_args[i + 2] = index.start
-            else:
-                raise RuntimeError(
-                    "Render pipeline indices must be a 2-element tuple with ints or ranges."
-                )
-        if wgpu_index_buffer is not None:
-            base_vertex = 0  # A value added to each index before reading [...]
-            index_args.insert(-1, base_vertex)
-
-        # Process vertex buffers. Update the buffer, and produces a descriptor.
-        vertex_buffers = {}
-        vertex_buffer_descriptors = []
-        # todo: we can probably expose multiple attributes per buffer using a BufferView
-        # -> can we also leverage numpy here?
-        for slot, buffer in pipeline_info.get("vertex_buffers", {}).items():
-            slot = int(slot)
-            vbo_des = {
-                "array_stride": buffer.nbytes // buffer.nitems,
-                "step_mode": wgpu.VertexStepMode.vertex,  # vertex or instance
-                "attributes": [
-                    {
-                        "format": to_vertex_format(buffer.format),
-                        "offset": 0,
-                        "shader_location": slot,
-                    }
-                ],
-            }
-            vertex_buffers[slot] = buffer
-            vertex_buffer_descriptors.append(vbo_des)
-
-        # Get bind groups and pipeline layout from the buffers in pipeline_info.
-        # This also makes sure the buffers and textures are up-to-date.
-        bind_groups, pipeline_layout = self._get_bind_groups(pipeline_info)
-
-        # Compile shaders
-        vs_entry_point = fs_entry_point = "main"
-        vshader = pipeline_info["vertex_shader"]
-        if isinstance(vshader, tuple):
-            vshader, vs_entry_point = vshader
-        fshader = pipeline_info["fragment_shader"]
-        if isinstance(fshader, tuple):
-            fshader, fs_entry_point = fshader
-        vs_module = self._get_shader_module(vshader, vshader)
-        fs_module = self._get_shader_module(fshader, fshader)
-
-        # Instantiate the pipeline object
-        # todo: is this how strip_index_format is supposed to work?
-        strip_index_format = 0
-        if "strip" in pipeline_info["primitive_topology"]:
-            strip_index_format = index_format
-        pipeline = device.create_render_pipeline(
-            layout=pipeline_layout,
-            vertex={
-                "module": vs_module,
-                "entry_point": vs_entry_point,
-                "buffers": vertex_buffer_descriptors,
-            },
-            primitive={
-                "topology": pipeline_info["primitive_topology"],
-                "strip_index_format": strip_index_format,
-                "front_face": wgpu.FrontFace.ccw,
-                "cull_mode": pipeline_info.get("cull_mode", wgpu.CullMode.none),
-            },
-            depth_stencil={
-                "format": self._depth_texture.format,
-                "depth_write_enabled": True,  # optional
-                "depth_compare": wgpu.CompareFunction.less,  # optional
-                "stencil_front": {},  # use defaults
-                "stencil_back": {},  # use defaults
-                "depth_bias": 0,
-                "depth_bias_slope_scale": 0.0,
-                "depth_bias_clamp": 0.0,
-            },
-            multisample={
-                "count": self._msaa,
-                "mask": 0xFFFFFFFF,
-                "alpha_to_coverage_enabled": False,
-            },
-            fragment={
-                "module": fs_module,
-                "entry_point": fs_entry_point,
-                "targets": [
-                    {
-                        "format": self._render_texture.format,
-                        "blend": {
-                            "alpha": (
-                                wgpu.BlendFactor.one,
-                                wgpu.BlendFactor.zero,
-                                wgpu.BlendOperation.add,
-                            ),
-                            "color": (
-                                wgpu.BlendFactor.src_alpha,
-                                wgpu.BlendFactor.one_minus_src_alpha,
-                                wgpu.BlendOperation.add,
-                            ),
-                        },
-                        "write_mask": wgpu.ColorWrite.ALL,
-                    },
-                    {
-                        "format": self._pick_texture.format,
-                        "blend": None,
-                        "write_mask": wgpu.ColorWrite.ALL,
-                    },
-                ],
-            },
-        )
-
-        return {
-            "pipeline": pipeline,  # wgpu object
-            "index_args": index_args,  # tuple
-            "index_buffer": wgpu_index_buffer,  # Buffer
-            "vertex_buffers": vertex_buffers,  # dict of slot -> Buffer
-            "bind_groups": bind_groups,  # list of wgpu bind_group objects
-        }
-
-    def _get_bind_groups(self, pipeline_info):
-        """Given high-level information on bindings, create the corresponding
-        wgpu objects. This assumes that all buffers and textures are up-to-date.
-        Returns (bind_groups, pipeline_layout).
-        """
-        # todo: cache bind_group_layout objects
-        # todo: cache pipeline_layout objects
-
-        device = self.device
-
-        # Collect resource groups (keys e.g. "bindings1", "bindings132")
-        resource_groups = []
-        for key in pipeline_info.keys():
-            if key.startswith("bindings"):
-                i = int(key[len("bindings") :])
-                assert i >= 0
-                if not pipeline_info[key]:
-                    continue
-                while len(resource_groups) <= i:
-                    resource_groups.append({})
-                resource_groups[i] = pipeline_info[key]
-
-        # Create bind groups and bind group layouts
-        bind_groups = []
-        bind_group_layouts = []
-        for resources in resource_groups:
-            if not isinstance(resources, dict):
-                resources = {slot: resource for slot, resource in enumerate(resources)}
-            # Collect list of dicts
-            bindings = []
-            binding_layouts = []
-            for slot, binding in resources.items():
-                resource = binding.resource
-                subtype = binding.type.partition("/")[2]
-
-                if binding.type.startswith("buffer/"):
-                    assert isinstance(resource, Buffer)
-                    bindings.append(
-                        {
-                            "binding": slot,
-                            "resource": {
-                                "buffer": resource._wgpu_buffer[1],
-                                "offset": 0,
-                                "size": resource.nbytes,
-                            },
-                        }
-                    )
-                    binding_layouts.append(
-                        {
-                            "binding": slot,
-                            "visibility": binding.visibility,
-                            "buffer": {
-                                "type": getattr(wgpu.BufferBindingType, subtype),
-                                "has_dynamic_offset": False,
-                                "min_binding_size": 0,
-                            },
-                        }
-                    )
-                elif binding.type.startswith("sampler/"):
-                    assert isinstance(resource, TextureView)
-                    bindings.append(
-                        {"binding": slot, "resource": resource._wgpu_sampler[1]}
-                    )
-                    binding_layouts.append(
-                        {
-                            "binding": slot,
-                            "visibility": binding.visibility,
-                            "sampler": {
-                                "type": getattr(wgpu.SamplerBindingType, subtype),
-                            },
-                        }
-                    )
-                elif binding.type.startswith("texture/"):
-                    assert isinstance(resource, TextureView)
-                    bindings.append(
-                        {"binding": slot, "resource": resource._wgpu_texture_view[1]}
-                    )
-                    dim = resource.view_dim
-                    dim = getattr(wgpu.TextureViewDimension, dim, dim)
-                    sample_type = getattr(wgpu.TextureSampleType, subtype, subtype)
-                    # Derive sample type from texture
-                    if sample_type == "auto":
-                        fmt = to_texture_format(resource.format)
-                        fmt = ALTTEXFORMAT.get(fmt, [fmt])[0]
-                        if "float" in fmt or "norm" in fmt:
-                            sample_type = wgpu.TextureSampleType.float
-                            # For float32 wgpu does not allow the sampler to be filterable,
-                            # except when the native-only feature
-                            # TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES is set,
-                            # which wgpu-py does by default.
-                            # if "32float" in fmt:
-                            #     sample_type = wgpu.TextureSampleType.unfilterable_float
-                        elif "uint" in fmt:
-                            sample_type = wgpu.TextureSampleType.uint
-                        elif "sint" in fmt:
-                            sample_type = wgpu.TextureSampleType.sint
-                        elif "depth" in fmt:
-                            sample_type = wgpu.TextureSampleType.depth
-                        else:
-                            raise ValueError("Could not determine texture sample type.")
-                    binding_layouts.append(
-                        {
-                            "binding": slot,
-                            "visibility": binding.visibility,
-                            "texture": {
-                                "sample_type": sample_type,
-                                "view_dimension": dim,
-                                "multisampled": False,
-                            },
-                        }
-                    )
-                elif binding.type.startswith("storage_texture/"):
-                    assert isinstance(resource, TextureView)
-                    bindings.append(
-                        {"binding": slot, "resource": resource._wgpu_texture_view[1]}
-                    )
-                    dim = resource.view_dim
-                    dim = getattr(wgpu.TextureViewDimension, dim, dim)
-                    fmt = to_texture_format(resource.format)
-                    fmt = ALTTEXFORMAT.get(fmt, [fmt])[0]
-                    binding_layouts.append(
-                        {
-                            "binding": slot,
-                            "visibility": binding.visibility,
-                            "storage_texture": {
-                                "access": getattr(wgpu.StorageTextureAccess, subtype),
-                                "format": fmt,
-                                "view_dimension": dim,
-                            },
-                        }
-                    )
-
-            # Create wgpu objects
-            bind_group_layout = device.create_bind_group_layout(entries=binding_layouts)
-            bind_group = device.create_bind_group(
-                layout=bind_group_layout, entries=bindings
-            )
-            bind_groups.append(bind_group)
-            bind_group_layouts.append(bind_group_layout)
-
-        # Create pipeline layout object from list of layouts
-        pipeline_layout = device.create_pipeline_layout(
-            bind_group_layouts=bind_group_layouts
-        )
-
-        return bind_groups, pipeline_layout
-
-    def _update_buffer(self, resource):
-        device = self.device
-        buffer = getattr(resource, "_wgpu_buffer", (-1, None))[1]
-
-        # todo: dispose an old buffer? / reuse an old buffer?
-
-        pending_uploads = resource._pending_uploads
-        resource._pending_uploads = []
-        bytes_per_item = resource.nbytes // resource.nitems
-
-        # Create buffer if needed
-        if buffer is None or buffer.size != resource.nbytes:
-            resource._wgpu_usage |= wgpu.BufferUsage.COPY_DST
-            buffer = device.create_buffer(
-                size=resource.nbytes, usage=resource._wgpu_usage
-            )
-
-        queue = device.queue
-        encoder = device.create_command_encoder()
-
-        # Upload any pending data
-        for offset, size in pending_uploads:
-            subdata = resource._get_subdata(offset, size)
-            # A: map the buffer, writes to it, then unmaps. But we don't offer a mapping API in wgpu-py
-            # B: roll data in new buffer, copy from there to existing buffer
-            tmp_buffer = device.create_buffer_with_data(
-                data=subdata,
-                usage=wgpu.BufferUsage.COPY_SRC,
-            )
-            boffset, bsize = bytes_per_item * offset, bytes_per_item * size
-            encoder.copy_buffer_to_buffer(tmp_buffer, 0, buffer, boffset, bsize)
-            # C: using queue. This may be sugar for B, but it may also be optimized
-            # Unfortunately, this seems to crash the device :/
-            # queue.write_buffer(buffer, bytes_per_item * offset, subdata, 0, subdata.nbytes)
-            # D: A staging buffer/belt https://github.com/gfx-rs/wgpu-rs/blob/master/src/util/belt.rs
-            # todo: look into staging buffers?
-
-        queue.submit([encoder.finish()])
-        resource._wgpu_buffer = resource.rev, buffer
-
-    def _update_texture_view(self, resource):
-        if resource._is_default_view:
-            texture_view = resource.texture._wgpu_texture[1].create_view()
-        else:
-            dim = resource._view_dim
-            assert resource._mip_range.step == 1
-            assert resource._layer_range.step == 1
-            fmt = to_texture_format(resource.format)
-            fmt = ALTTEXFORMAT.get(fmt, [fmt])[0]
-            texture_view = resource.texture._wgpu_texture[1].create_view(
-                format=fmt,
-                dimension=f"{dim}d" if isinstance(dim, int) else dim,
-                aspect=resource._aspect,
-                base_mip_level=resource._mip_range.start,
-                mip_level_count=len(resource._mip_range),
-                base_array_layer=resource._layer_range.start,
-                array_layer_count=len(resource._layer_range),
-            )
-        resource._wgpu_texture_view = resource.rev, texture_view
-
-    def _update_texture(self, resource):
-
-        texture = getattr(resource, "_wgpu_texture", (-1, None))[1]
-        pending_uploads = resource._pending_uploads
-        resource._pending_uploads = []
-
-        fmt = to_texture_format(resource.format)
-        pixel_padding = None
-        if fmt in ALTTEXFORMAT:
-            fmt, pixel_padding, extra_bytes = ALTTEXFORMAT[fmt]
-
-        # Create texture if needed
-        if texture is None:  # todo: or needs to be replaced (e.g. resized)
-            resource._wgpu_usage |= wgpu.TextureUsage.COPY_DST
-            texture = self.device.create_texture(
-                size=resource.size,
-                usage=resource._wgpu_usage,
-                dimension=f"{resource.dim}d",
-                format=getattr(wgpu.TextureFormat, fmt),
-                mip_level_count=1,
-                sample_count=1,  # msaa?
-            )  # todo: let resource specify mip_level_count and sample_count
-
-        bytes_per_pixel = resource.nbytes // (
-            resource.size[0] * resource.size[1] * resource.size[2]
-        )
-        if pixel_padding is not None:
-            bytes_per_pixel += extra_bytes
-
-        queue = self.device.queue
-        encoder = self.device.create_command_encoder()
-
-        # Upload any pending data
-        for offset, size in pending_uploads:
-            subdata = resource._get_subdata(offset, size, pixel_padding)
-            # B: using a temp buffer
-            # tmp_buffer = self.device.create_buffer_with_data(data=subdata,
-            #     usage=wgpu.BufferUsage.COPY_SRC,
-            # )
-            # encoder.copy_buffer_to_texture(
-            #     {
-            #         "buffer": tmp_buffer,
-            #         "offset": 0,
-            #         "bytes_per_row": size[0] * bytes_per_pixel,  # multiple of 256
-            #         "rows_per_image": size[1],
-            #     },
-            #     {
-            #         "texture": texture,
-            #         "mip_level": 0,
-            #         "origin": offset,
-            #     },
-            #     copy_size=size,
-            # )
-            # C: using the queue, which may be doing B, but may also be optimized,
-            #    and the bytes_per_row limitation does not apply here
-            queue.write_texture(
-                {"texture": texture, "origin": offset, "mip_level": 0},
-                subdata,
-                {"bytes_per_row": size[0] * bytes_per_pixel, "rows_per_image": size[1]},
-                size,
-            )
-
-        queue.submit([encoder.finish()])
-        resource._wgpu_texture = resource.rev, texture
-
-    def _update_sampler(self, resource):
-        # A sampler's info (and raw object) are stored on a TextureView
-        amodes = resource._address_mode.replace(",", " ").split() or ["clamp"]
-        while len(amodes) < 3:
-            amodes.append(amodes[-1])
-        filters = resource._filter.replace(",", " ").split() or ["nearest"]
-        while len(filters) < 3:
-            filters.append(filters[-1])
-        ammap = {"clamp": "clamp-to-edge", "mirror": "mirror-repeat"}
-        sampler = self.device.create_sampler(
-            address_mode_u=ammap.get(amodes[0], amodes[0]),
-            address_mode_v=ammap.get(amodes[1], amodes[1]),
-            address_mode_w=ammap.get(amodes[2], amodes[2]),
-            mag_filter=filters[0],
-            min_filter=filters[1],
-            mipmap_filter=filters[2],
-            # lod_min_clamp -> use default 0
-            # lod_max_clamp -> use default inf
-            # compare -> only not-None for comparison samplers!
-        )
-        resource._wgpu_sampler = resource.rev, sampler
-
-    def _get_shader_module(self, key, source):
-        """Compile a shader module object, or re-use it from the cache."""
-        # todo: make this work for objects following the ShaderSourceTemplate interface
-        # todo: also release shader modules that are no longer used
-        if key not in self._shader_cache:
-            m = self.device.create_shader_module(code=source)
-            self._shader_cache[key] = m
-        return self._shader_cache[key]
-
-    # Picking
-
-    def get_pick_info(self, pos):
-        """Get information about the given window location. The given
-        pos is a 2D point in logical pixels (with the origin at the
-        top-left). Returns a dict with fields:
-
-        * "ndc": The position in normalized device coordinates, the 3d element
-            being the depth (0..1). Can be translated to the position
-            in world coordinates using the camera transforms.
-        * "rgba": The value in the color buffer. All zero's when rendering
-          directly to the screen (bypassing post-processing).
-        * "world_object": the object at that location (provided that
-          the object supports picking).
-        * Additional pick info may be available, depending on the type of
-          object and its material. See the world-object classes for details.
-        """
-
-        # Make pos 0..1, so we can scale it to the render texture
-        _, logical_size = get_size_from_render_target(self._target)
-        float_pos = pos[0] / logical_size[0], pos[1] / logical_size[1]
-
-        can_sample_color = self._render_texture.texture is not None
-
-        # Sample
-        encoder = self.device.create_command_encoder()
-        self._copy_pixel(encoder, self._depth_texture, float_pos, 0)
-        if can_sample_color:
-            self._copy_pixel(encoder, self._render_texture, float_pos, 8)
-        self._copy_pixel(encoder, self._pick_texture, float_pos, 16)
-        queue = self.device.queue
-        queue.submit([encoder.finish()])
-
-        # Collect data from the buffer
-        data = self._pixel_info_buffer.map_read()
-        depth = data[0:4].cast("f")[0]
-        color = tuple(data[8:12].cast("B"))
-        pick_value = tuple(data[16:32].cast("i"))
-        wobject = self._pick_map.get(pick_value[0], None)
-        # Note: the position in world coordinates is not included because
-        # it depends on the camera, but we don't "own" the camera.
-
-        info = {
-            "ndc": (2 * float_pos[0] - 1, 2 * float_pos[1] - 1, depth),
-            "rgba": color if can_sample_color else (0, 0, 0, 0),
-            "world_object": wobject,
-        }
-
-        if wobject and wobject.material is not None:
-            pick_info = wobject._wgpu_get_pick_info(pick_value)
-            info.update(pick_info)
-        return info
-
-    def _copy_pixel(self, encoder, render_texture, float_pos, buf_offset):
-
-        # Map position to the texture index
-        w, h, d = render_texture.size
-        x = max(0, min(w - 1, int(float_pos[0] * w)))
-        y = max(0, min(h - 1, int(float_pos[1] * h)))
-
-        # Note: bytes_per_row must be a multiple of 256.
-        encoder.copy_texture_to_buffer(
-            {
-                "texture": render_texture.texture,
-                "mip_level": 0,
-                "origin": (x, y, 0),
-            },
-            {
-                "buffer": self._pixel_info_buffer,
-                "offset": buf_offset,
-                "bytes_per_row": 256,  # render_texture.bytes_per_pixel,
-                "rows_per_image": 1,
-            },
-            copy_size=(1, 1, 1),
-        )
-
-    def snapshot(self):
-        """Create a snapshot of the currently rendered image."""
-
-        # Prepare
-        rt = self._render_texture
-        size = rt.size
-        bytes_per_pixel = 4
-
-        # Note, with queue.read_texture the bytes_per_row limitation does not apply.
-        data = self.device.queue.read_texture(
-            {
-                "texture": rt.texture,
-                "mip_level": 0,
-                "origin": (0, 0, 0),
-            },
-            {
-                "offset": 0,
-                "bytes_per_row": bytes_per_pixel * size[0],
-                "rows_per_image": size[1],
-            },
-            size,
-        )
-
-        return np.frombuffer(data, np.uint8).reshape(size[1], size[0], 4)
diff --git a/pygfx/renderers/wgpu/backgroundrender.py b/pygfx/renderers/wgpu/backgroundrender.py
index 8fcbd90..26f5ffb 100644
--- a/pygfx/renderers/wgpu/backgroundrender.py
+++ b/pygfx/renderers/wgpu/backgroundrender.py
@@ -2,17 +2,18 @@ import wgpu  # only for flags/enums
 
 from . import register_wgpu_render_function
 from ._shadercomposer import Binding, WorldObjectShader
-from ._conv import to_texture_format
+from ._utils import to_texture_format
 from ...objects import Background
 from ...materials import BackgroundMaterial, BackgroundImageMaterial
 from ...resources import Texture, TextureView
 
 
 @register_wgpu_render_function(Background, BackgroundMaterial)
-def background_renderer(wobject, render_info):
+def background_renderer(render_info):
 
+    wobject = render_info.wobject
     material = wobject.material
-    shader = BackgroundShader(wobject, texture_dim="")
+    shader = BackgroundShader(render_info, texture_dim="")
 
     bindings = {}
 
@@ -47,11 +48,9 @@ def background_renderer(wobject, render_info):
     for i, binding in bindings.items():
         shader.define_binding(0, i, binding)
 
-    wgsl = shader.generate_wgsl()
     return [
         {
-            "vertex_shader": (wgsl, "vs_main"),
-            "fragment_shader": (wgsl, "fs_main"),
+            "render_shader": shader,
             "primitive_topology": wgpu.PrimitiveTopology.triangle_strip,
             "indices": 4,
             "bindings0": bindings,
@@ -63,35 +62,22 @@ class BackgroundShader(WorldObjectShader):
     def get_code(self):
         return (
             self.get_definitions()
-            + self.more_definitions()
             + self.common_functions()
             + self.vertex_shader()
             + self.fragment_shader()
         )
 
-    def more_definitions(self):
+    def vertex_shader(self):
         return """
 
         struct VertexInput {
             [[builtin(vertex_index)]] index : u32;
         };
-        struct VertexOutput {
-            [[location(0)]] texcoord: vec3<f32>;
-            [[location(1)]] world_pos: vec3<f32>;
-            [[builtin(position)]] position: vec4<f32>;
-        };
 
-        struct FragmentOutput {
-            [[location(0)]] color: vec4<f32>;
-            [[location(1)]] pick: vec4<i32>;
-        };
-    """
 
-    def vertex_shader(self):
-        return """
         [[stage(vertex)]]
-        fn vs_main(in: VertexInput) -> VertexOutput {
-            var out: VertexOutput;
+        fn vs_main(in: VertexInput) -> Varyings {
+            var varyings: Varyings;
             // Define positions at the four corners of the viewport, at the largest depth
             var positions = array<vec2<f32>, 4>(
                 vec2<f32>(-1.0, -1.0),
@@ -107,48 +93,59 @@ class BackgroundShader(WorldObjectShader):
                 let wpos1 = ndc_to_world_pos(ndc_pos1);
                 let wpos2 = ndc_to_world_pos(ndc_pos2);
                 // Store positions and the view direction in the world
-                out.position = ndc_pos1;
-                out.world_pos = wpos1;
-                out.texcoord = wpos2.xyz - wpos1.xyz;
+                varyings.position = vec4<f32>(ndc_pos1);
+                varyings.world_pos = vec3<f32>(wpos1);
+                varyings.texcoord = vec3<f32>(wpos2.xyz - wpos1.xyz);
             $$ else
                 // Store positions and the view direction in the world
-                out.position = vec4<f32>(pos, 0.9999999, 1.0);
-                out.world_pos = ndc_to_world_pos(out.position);
-                out.texcoord = vec3<f32>(pos * 0.5 + 0.5, 0.0);
+                varyings.position = vec4<f32>(pos, 0.9999999, 1.0);
+                varyings.world_pos = vec3<f32>(ndc_to_world_pos(out.position));
+                varyings.texcoord = vec3<f32>(pos * 0.5 + 0.5, 0.0);
             $$ endif
-            return out;
+            return varyings;
         }
         """
 
     def fragment_shader(self):
         return """
         [[stage(fragment)]]
-        fn fs_main(in: VertexOutput) -> FragmentOutput {
-            var out: FragmentOutput;
+        fn fs_main(varyings: Varyings) -> FragmentOutput {
+            var final_color : vec4<f32>;
             $$ if texture_dim
                 $$ if texture_dim == '2d'
-                    let color = textureSample(r_tex, r_sampler, in.texcoord.xy);
+                    let color = textureSample(r_tex, r_sampler, varyings.texcoord.xy);
                 $$ elif texture_dim == 'cube'
-                    let color = textureSample(r_tex, r_sampler, in.texcoord.xyz);
+                    let color = textureSample(r_tex, r_sampler, varyings.texcoord.xyz);
                 $$ endif
                 $$ if texture_nchannels == 1
-                    out.color = vec4<f32>(color.rrr, 1.0);
+                    final_color = vec4<f32>(color.rrr, 1.0);
                 $$ elif texture_nchannels == 2
-                    out.color = vec4<f32>(color.rrr, color.g);
+                    final_color = vec4<f32>(color.rrr, color.g);
                 $$ else
-                    out.color = color;
+                    final_color = color;
                 $$ endif
             $$ else
-                let f = in.texcoord.xy;
-                out.color = (
+                let f = varyings.texcoord.xy;
+                final_color = (
                     u_material.color_bottom_left * (1.0 - f.x) * (1.0 - f.y)
                     + u_material.color_bottom_right * f.x * (1.0 - f.y)
                     + u_material.color_top_left * (1.0 - f.x) * f.y
                     + u_material.color_top_right * f.x * f.y
                 );
             $$ endif
-            out.color.a = out.color.a * u_material.opacity;
-            apply_clipping_planes(in.world_pos);
-            return out;
+            final_color.a = final_color.a * u_material.opacity;
+
+            // We can apply clipping planes, but maybe a background should not be clipped?
+            // apply_clipping_planes(in.world_pos);
+
+            $$ if render_pass == 1
+                // Fake being opaque - backgrounds should be backgrounds
+                add_fragment(varyings.position.z, vec4<f32>(final_color.rgb, 1.0));
+                var out = finalize_fragment();
+                out.color = final_color;
+                return out;
+            $$ else
+                discard;
+            $$ endif
         }
         """
diff --git a/pygfx/renderers/wgpu/linerender.py b/pygfx/renderers/wgpu/linerender.py
index 775c91e..186fa5b 100644
--- a/pygfx/renderers/wgpu/linerender.py
+++ b/pygfx/renderers/wgpu/linerender.py
@@ -42,12 +42,13 @@ renderer_uniform_type = dict(last_i="i4")
 
 
 @register_wgpu_render_function(Line, LineMaterial)
-def line_renderer(wobject, render_info):
+def line_renderer(render_info):
     """Render function capable of rendering lines."""
 
+    wobject = render_info.wobject
     material = wobject.material
     geometry = wobject.geometry
-    shader = LineShader(wobject)
+    shader = LineShader(render_info)
 
     assert isinstance(material, LineMaterial)
 
@@ -110,11 +111,9 @@ def line_renderer(wobject, render_info):
         shader.define_binding(0, i, binding)
 
     # Done
-    wgsl = shader.generate_wgsl()
     return [
         {
-            "vertex_shader": (wgsl, "vs_main"),
-            "fragment_shader": (wgsl, "fs_main"),
+            "render_shader": shader,
             "primitive_topology": wgpu.PrimitiveTopology.triangle_strip,
             "indices": (n, 1),
             "bindings0": bindings,
@@ -142,14 +141,6 @@ class LineShader(WorldObjectShader):
         struct VertexInput {
             [[builtin(vertex_index)]] index : u32;
         };
-        struct VertexOutput {
-            [[location(0)]] thickness_p: f32;
-            [[location(1)]] vec_from_node_p: vec2<f32>;
-            [[location(2)]] color: vec4<f32>;
-            [[location(3)]] vertex_idx: vec2<f32>;
-            [[location(4)]] world_pos: vec3<f32>;
-            [[builtin(position)]] position: vec4<f32>;
-        };
 
         struct VertexFuncOutput {
             i: i32;
@@ -189,7 +180,7 @@ class LineShader(WorldObjectShader):
             core
             + """
         [[stage(vertex)]]
-        fn vs_main(in: VertexInput) -> VertexOutput {
+        fn vs_main(in: VertexInput) -> Varyings {
 
             let index = i32(in.index);
             let screen_factor = u_stdinfo.logical_size.xy / 2.0;
@@ -198,20 +189,15 @@ class LineShader(WorldObjectShader):
 
             let result: VertexFuncOutput = get_vertex_result(index, screen_factor, half_thickness, l2p);
 
-            var out: VertexOutput;
-            out.world_pos = ndc_to_world_pos(result.pos);
-            out.position = result.pos;
-            out.thickness_p = result.thickness_p;
-            out.vec_from_node_p = result.vec_from_node_p;
-            out.vertex_idx = vec2<f32>(f32(result.i / 10000), f32(result.i % 10000));
+            var varyings: Varyings;
+            varyings.position = vec4<f32>(result.pos);
+            varyings.world_pos = vec3<f32>(ndc_to_world_pos(result.pos));
+            varyings.thickness_p = f32(result.thickness_p);
+            varyings.vec_from_node_p = vec2<f32>(result.vec_from_node_p);
+            varyings.color = vec4<f32>(load_s_colors(result.i));
+            varyings.pick_idx = vec2<f32>(f32(result.i / 10000), f32(result.i % 10000));
 
-            $$ if per_vertex_color
-            out.color = load_s_colors(result.i);
-            $$ else:
-            out.color = u_material.color;
-            $$ endif
-
-            return out;
+            return varyings;
         }
         """
         )
@@ -448,42 +434,55 @@ class LineShader(WorldObjectShader):
     def fragment_shader(self):
         return """
         [[stage(fragment)]]
-        fn fs_main(in: VertexOutput) -> FragmentOutput {
+        fn fs_main(varyings: Varyings) -> FragmentOutput {
 
             // Discard fragments outside of the radius. This is what makes round
             // joins and caps. If we ever want bevel or miter joins, we should
             // change the vertex positions a bit, and drop these lines below.
-            let dist_to_node_p = length(in.vec_from_node_p);
-            if (dist_to_node_p > in.thickness_p * 0.5) {
+            let dist_to_node_p = length(varyings.vec_from_node_p);
+            if (dist_to_node_p > varyings.thickness_p * 0.5) {
                 discard;
             }
 
             // Prep
-            var out: FragmentOutput;
             var alpha: f32 = 1.0;
 
-            // Anti-aliasing. Note that because of the discarding above, we cannot
-            // use MSAA for aa. But maybe we use another generic approach to aa. We'll see.
-            // todo: because of this, our line gets a wee bit thinner, so we have to
+            // Anti-aliasing. Note that because of the discarding above, we cannot use MSAA.
+            // By default, the renderer uses SSAA (super-sampling), but if we apply AA for the edges
+            // here this will help the end result. Because this produces semitransparent fragments,
+            // it relies on a good blend method, and the object gets drawn twice.
+            // todo: because of this aa edge, our line gets a wee bit thinner, so we have to
             // output ticker lines in the vertex shader!
-            let aa_width = 1.2;
-            alpha = ((0.5 * in.thickness_p) - abs(dist_to_node_p)) / aa_width;
-            alpha = pow(min(1.0, alpha), 2.0);
-
-            // The outer edges with lower alpha for aa are pushed a bit back to avoid artifacts.
-            out.depth = in.position.z + 0.0001 * (0.8 - min(0.8, alpha));
+            let aa_width = 1.0;
+            alpha = ((0.5 * varyings.thickness_p) - abs(dist_to_node_p)) / aa_width;
+            alpha = clamp(alpha, 0.0, 1.0);
+            alpha = pow(alpha, 2.0);
 
             // Set color
-            let color = in.color;
-            out.color = vec4<f32>(color.rgb, min(1.0, color.a) * alpha);
+            $$ if per_vertex_color
+            let color = varyings.color;
+            $$ else
+            let color = u_material.color;
+            $$ endif
+
+            let final_color = vec4<f32>(color.rgb, min(1.0, color.a) * alpha * u_material.opacity);
+
+            // Wrap up
+            apply_clipping_planes(varyings.world_pos);
+            add_fragment(varyings.position.z, final_color);
+            var out = finalize_fragment();
 
             // Set picking info. Yes, the vertex_id interpolates correctly in encoded form.
-            let vf: f32 = in.vertex_idx.x * 10000.0 + in.vertex_idx.y;
+            $$ if render_pass == 1
+            let vf: f32 = varyings.pick_idx.x * 10000.0 + varyings.pick_idx.y;
             let vi = i32(vf + 0.5);
             out.pick = vec4<i32>(u_wobject.id, 0, vi, i32((vf - f32(vi)) * 1048576.0));
+            $$ endif
+
+            // The outer edges with lower alpha for aa are pushed a bit back to avoid artifacts.
+            // This is only necessary for blend method "simple1"
+            //out.depth = varyings.position.z + 0.0001 * (0.8 - min(0.8, alpha));
 
-            out.color.a = out.color.a * u_material.opacity;
-            apply_clipping_planes(in.world_pos);
             return out;
         }
         """
@@ -491,12 +490,13 @@ class LineShader(WorldObjectShader):
 
 @register_wgpu_render_function(Line, LineThinSegmentMaterial)
 @register_wgpu_render_function(Line, LineThinMaterial)
-def thin_line_renderer(wobject, render_info):
+def thin_line_renderer(render_info):
     """Render function capable of rendering lines."""
 
+    wobject = render_info.wobject
     material = wobject.material
     geometry = wobject.geometry
-    shader = ThinLineShader(wobject)
+    shader = ThinLineShader(render_info)
 
     positions1 = geometry.positions
 
@@ -526,11 +526,9 @@ def thin_line_renderer(wobject, render_info):
     for i, binding in bindings.items():
         shader.define_binding(0, i, binding)
 
-    wgsl = shader.generate_wgsl()
     return [
         {
-            "vertex_shader": (wgsl, "vs_main"),
-            "fragment_shader": (wgsl, "fs_main"),
+            "render_shader": shader,
             "primitive_topology": primitive,
             "indices": (positions1.nitems, 1),
             "vertex_buffers": vertex_buffers,
@@ -543,12 +541,12 @@ class ThinLineShader(WorldObjectShader):
     def get_code(self):
         return (
             self.get_definitions()
-            + self.more_definitions()
+            + self.common_functions()
             + self.vertex_shader()
             + self.fragment_shader()
         )
 
-    def more_definitions(self):
+    def vertex_shader(self):
         return """
 
         struct VertexInput {
@@ -558,46 +556,36 @@ class ThinLineShader(WorldObjectShader):
             $$ endif
             [[builtin(vertex_index)]] index : u32;
         };
-        struct VertexOutput {
-            $$ if per_vertex_color
-            [[location(0)]] color: vec4<f32>;
-            $$ endif
-            [[builtin(position)]] position: vec4<f32>;
-        };
 
-        struct FragmentOutput {
-            [[location(0)]] color: vec4<f32>;
-            [[location(1)]] pick: vec4<i32>;
-        };
-        """
 
-    def vertex_shader(self):
-        return """
         [[stage(vertex)]]
-        fn vs_main(in: VertexInput) -> VertexOutput {
+        fn vs_main(in: VertexInput) -> Varyings {
 
             let wpos = u_wobject.world_transform * vec4<f32>(in.pos.xyz, 1.0);
             let npos = u_stdinfo.projection_transform * u_stdinfo.cam_transform * wpos;
 
-            var out: VertexOutput;
-            out.position = npos;
-            $$ if per_vertex_color
-            out.color = in.color;
-            $$ endif
-            return out;
+            var varyings: Varyings;
+            varyings.position = vec4<f32>(npos);
+            varyings.world_pos = vec3<f32>(ndc_to_world_pos(npos));
+            varyings.color = vec4<f32>(in.color);
+            return varyings;
         }
         """
 
     def fragment_shader(self):
         return """
         [[stage(fragment)]]
-        fn fs_main(in: VertexOutput) -> FragmentOutput {
-            var out : FragmentOutput;
+        fn fs_main(varyings: Varyings) -> FragmentOutput {
             $$ if per_vertex_color
-            out.color = in.color;
+            let color = varyings.color;
             $$ else
-            out.color = u_material.color;
+            let color = u_material.color;
             $$ endif
-            return out;
+
+            let final_color = vec4<f32>(color.rgb, color.a * u_material.opacity);
+
+            apply_clipping_planes(varyings.world_pos);
+            add_fragment(varyings.position.z, final_color);
+            return finalize_fragment();
         }
         """
diff --git a/pygfx/renderers/wgpu/meshrender.py b/pygfx/renderers/wgpu/meshrender.py
index 0dce4f7..7daac60 100644
--- a/pygfx/renderers/wgpu/meshrender.py
+++ b/pygfx/renderers/wgpu/meshrender.py
@@ -2,7 +2,7 @@ import wgpu  # only for flags/enums
 
 from . import register_wgpu_render_function
 from ._shadercomposer import Binding, WorldObjectShader
-from ._conv import to_vertex_format, to_texture_format
+from ._utils import to_vertex_format, to_texture_format
 from ...objects import Mesh, InstancedMesh
 from ...materials import (
     MeshBasicMaterial,
@@ -17,25 +17,24 @@ from ...utils import normals_from_vertices
 
 
 @register_wgpu_render_function(Mesh, MeshBasicMaterial)
-def mesh_renderer(wobject, render_info):
+def mesh_renderer(render_info):
     """Render function capable of rendering meshes."""
+    wobject = render_info.wobject
     geometry = wobject.geometry
     material = wobject.material  # noqa
 
     # Initialize
     topology = wgpu.PrimitiveTopology.triangle_list
     shader = MeshShader(
-        wobject,
+        render_info,
         lighting="",
-        need_normals=False,
         texture_dim="",
         texture_format="f32",
         instanced=False,
         climcorrection=None,
         wireframe=material.wireframe,
+        normal_color=False,
     )
-    vs_entry_point = "vs_main"
-    fs_entry_point = "fs_main"
 
     # We're assuming the presence of an index buffer for now
     assert getattr(geometry, "indices", None)
@@ -125,31 +124,27 @@ def mesh_renderer(wobject, render_info):
     # Collect texture and sampler
     if isinstance(material, MeshNormalMaterial):
         # Special simple fragment shader
-        fs_entry_point = "fs_normal_color"
         shader["texture_dim"] = ""  # disable texture if there happens to be one
-        shader["need_normals"] = True
+        shader["normal_color"] = True
     elif isinstance(material, MeshNormalLinesMaterial):
         # Special simple vertex shader with plain fragment shader
         topology = wgpu.PrimitiveTopology.line_list
-        vs_entry_point = "vs_normal_lines"
-        shader["texture_dim"] = ""  # disable texture if there happens to be one
+        shader.vertex_shader = shader.vertex_shader_normal_lines
         index_buffer = None
         n = geometry.positions.nitems * 2
-        shader["need_normals"] = True
+        shader["texture_dim"] = ""  # disable texture if there happens to be one
+        shader["lighting"] = ""
+        shader["wireframe"] = False
     elif isinstance(material, MeshFlatMaterial):
         shader["lighting"] = "flat"
-        shader["need_normals"] = True
     elif isinstance(material, MeshPhongMaterial):
         shader["lighting"] = "phong"
-        shader["need_normals"] = True
     else:
         pass  # simple lighting
 
     # Instanced meshes have an extra storage buffer that we add manually
     n_instances = 1
     if isinstance(wobject, InstancedMesh):
-        if vs_entry_point != "vs_main":
-            raise TypeError(f"Instanced mesh does not work with {material}")
         shader["instanced"] = True
         bindings2[0] = Binding(
             "s_submatrices", "buffer/read_only_storage", wobject.matrices, "VERTEX"
@@ -171,11 +166,9 @@ def mesh_renderer(wobject, render_info):
         shader.define_binding(1, i, binding)
 
     # Put it together!
-    wgsl = shader.generate_wgsl()
     return [
         {
-            "vertex_shader": (wgsl, vs_entry_point),
-            "fragment_shader": (wgsl, fs_entry_point),
+            "render_shader": shader,
             "primitive_topology": topology,
             "cull_mode": cull_mode,
             "indices": (range(n), range(n_instances)),
@@ -192,14 +185,13 @@ class MeshShader(WorldObjectShader):
     def get_code(self):
         return (
             self.get_definitions()
-            + self.more_definitions()
             + self.common_functions()
             + self.helpers()
             + self.vertex_shader()
             + self.fragment_shader()
         )
 
-    def more_definitions(self):
+    def vertex_shader(self):
         return """
 
         struct VertexInput {
@@ -208,24 +200,6 @@ class MeshShader(WorldObjectShader):
             [[builtin(instance_index)]] instance_index : u32;
             $$ endif
         };
-        struct VertexOutput {
-            [[location(0)]] texcoord: vec3<f32>;
-            [[location(1)]] normal: vec3<f32>;
-            [[location(2)]] view: vec3<f32>;
-            [[location(3)]] light: vec3<f32>;
-            [[location(4)]] face_idx: vec4<f32>;
-            [[location(5)]] face_coords: vec3<f32>;
-            [[location(6)]] world_pos: vec3<f32>;
-            $$ if wireframe
-            [[location(7)]] wireframe_coords: vec3<f32>;
-            $$ endif
-            [[builtin(position)]] position: vec4<f32>;
-        };
-
-        struct FragmentOutput {
-            [[location(0)]] color: vec4<f32>;
-            [[location(1)]] pick: vec4<i32>;
-        };
 
         $$ if instanced
         [[block]]
@@ -235,13 +209,10 @@ class MeshShader(WorldObjectShader):
         [[group(2), binding(0)]]
         var<storage,read> s_submatrices: BufferMat4;
         $$ endif
-        """
 
-    def vertex_shader(self):
-        return """
 
         [[stage(vertex)]]
-        fn vs_main(in: VertexInput) -> VertexOutput {
+        fn vs_main(in: VertexInput) -> Varyings {
 
             // Select what face we're at
             let index = i32(in.vertex_index);
@@ -281,35 +252,31 @@ class MeshShader(WorldObjectShader):
             $$ endif
 
             // Prepare output
-            var out: VertexOutput;
+            var varyings: Varyings;
 
             // Set position
-            out.world_pos =world_pos.xyz / world_pos.w;
-            out.position = vec4<f32>(ndc_pos.xyz, ndc_pos.w);
+            varyings.world_pos = vec3<f32>(world_pos.xyz / world_pos.w);
+            varyings.position = vec4<f32>(ndc_pos.xyz, ndc_pos.w);
 
             // Set texture coords
             $$ if texture_dim == '1d'
-            out.texcoord =vec3<f32>(load_s_texcoords(i0), 0.0, 0.0);
+            varyings.texcoord = f32(load_s_texcoords(i0));
             $$ elif texture_dim == '2d'
-            out.texcoord = vec3<f32>(load_s_texcoords(i0), 0.0);
+            varyings.texcoord = vec2<f32>(load_s_texcoords(i0));
             $$ elif texture_dim == '3d'
-            out.texcoord = load_s_texcoords(i0);
+            varyings.texcoord = vec3<f32>(load_s_texcoords(i0));
             $$ endif
 
             // Set the normal
-            $$ if need_normals
-                let raw_normal = load_s_normals(i0);
-                let world_pos_n = world_transform * vec4<f32>(raw_pos + raw_normal, 1.0);
-                let world_normal = normalize(world_pos_n - world_pos).xyz;
-                out.normal = world_normal;
-            $$ endif
+            let raw_normal = load_s_normals(i0);
+            let world_pos_n = world_transform * vec4<f32>(raw_pos + raw_normal, 1.0);
+            let world_normal = normalize(world_pos_n - world_pos).xyz;
+            varyings.normal = vec3<f32>(world_normal);
 
             // Vectors for lighting, all in world coordinates
-            $$ if lighting
-                let view_vec = normalize(ndc_to_world_pos(vec4<f32>(0.0, 0.0, 1.0, 1.0)));
-                out.view = view_vec;
-                out.light = view_vec;
-            $$ endif
+            let view_vec = normalize(ndc_to_world_pos(vec4<f32>(0.0, 0.0, 1.0, 1.0)));
+            varyings.view = vec3<f32>(view_vec);
+            varyings.light = vec3<f32>(view_vec);
 
             // Set wireframe barycentric-like coordinates
             $$ if wireframe
@@ -322,7 +289,7 @@ class MeshShader(WorldObjectShader):
                 var arr_wireframe_coords = array<vec3<f32>, 3>(
                     vec3<f32>(dist1, 0.0, 0.0), vec3<f32>(0.0, dist2, 0.0), vec3<f32>(0.0, 0.0, dist3)
                 );
-                out.wireframe_coords = arr_wireframe_coords[sub_index];  // in logical pixels
+                varyings.wireframe_coords = vec3<f32>(arr_wireframe_coords[sub_index]);  // in logical pixels
             $$ endif
 
             // Set varyings for picking. We store the face_index, and 3 weights
@@ -337,20 +304,26 @@ class MeshShader(WorldObjectShader):
             $$ else
                 let inst_index = 0;
             $$ endif
-            out.face_idx = vec4<f32>(
-                f32(inst_index / d), f32(inst_index % d), f32(face_index / d), f32(face_index % d)
-            );
-            var arr_face_coords = array<vec3<f32>, 3>(
-                vec3<f32>(1.0, 0.0, 0.0), vec3<f32>(0.0, 1.0, 0.0), vec3<f32>(0.0, 0.0, 1.0)
-            );
-            out.face_coords = arr_face_coords[sub_index];
 
-            return out;
+            varyings.pick_idx = vec4<f32>(f32(inst_index / d), f32(inst_index % d), f32(face_index / d), f32(face_index % d));
+            var arr_pick_coords = array<vec3<f32>, 3>(vec3<f32>(1.0, 0.0, 0.0), vec3<f32>(0.0, 1.0, 0.0), vec3<f32>(0.0, 0.0, 1.0));
+            varyings.pick_coords = vec3<f32>(arr_pick_coords[sub_index]);
+
+            return varyings;
         }
 
+    """
+
+    def vertex_shader_normal_lines(self):
+        return """
+
+        struct VertexInput {
+            [[builtin(vertex_index)]] vertex_index : u32;
+        };
+
 
         [[stage(vertex)]]
-        fn vs_normal_lines(in: VertexInput) -> VertexOutput {
+        fn vs_main(in: VertexInput) -> Varyings {
             let index = i32(in.vertex_index);
             let r = index % 2;
             let i0 = (index - r) / 2;
@@ -368,10 +341,15 @@ class MeshShader(WorldObjectShader):
             let world_pos = world_pos1 + f32(r) * world_normal * amplitude;
             let ndc_pos = u_stdinfo.projection_transform * u_stdinfo.cam_transform * world_pos;
 
-            var out: VertexOutput;
-            out.world_pos = vec3<f32>(world_pos.xyz / world_pos.w);
-            out.position = ndc_pos;
-            return out;
+            var varyings: Varyings;
+            varyings.world_pos = vec3<f32>(world_pos.xyz / world_pos.w);
+            varyings.position = vec4<f32>(ndc_pos);
+
+            // Stub varyings, because the mesh varyings are based on face index
+            varyings.pick_idx = vec4<f32>(0.0);
+            varyings.pick_coords = vec3<f32>(0.0);
+
+            return varyings;
         }
         """
 
@@ -379,33 +357,32 @@ class MeshShader(WorldObjectShader):
         return """
 
         [[stage(fragment)]]
-        fn fs_main(in: VertexOutput, [[builtin(front_facing)]] is_front: bool) -> FragmentOutput {
-            var out: FragmentOutput;
+        fn fs_main(varyings: Varyings, [[builtin(front_facing)]] is_front: bool) -> FragmentOutput {
             var color_value: vec4<f32>;
 
             $$ if texture_dim
                 $$ if texture_dim == '1d'
                     $$ if texture_format == 'f32'
-                        color_value = textureSample(r_tex, r_sampler, in.texcoord.x);
+                        color_value = textureSample(r_tex, r_sampler, varyings.texcoord.x);
                     $$ else
                         let texcoords_dim = f32(textureDimensions(r_tex);
-                        let texcoords_u = i32(in.texcoord.x * texcoords_dim % texcoords_dim);
+                        let texcoords_u = i32(varyings.texcoord.x * texcoords_dim % texcoords_dim);
                         color_value = vec4<f32>(textureLoad(r_tex, texcoords_u, 0));
                     $$ endif
                 $$ elif texture_dim == '2d'
                     $$ if texture_format == 'f32'
-                        color_value = textureSample(r_tex, r_sampler, in.texcoord.xy);
+                        color_value = textureSample(r_tex, r_sampler, varyings.texcoord.xy);
                     $$ else
                         let texcoords_dim = vec2<f32>(textureDimensions(r_tex));
-                        let texcoords_u = vec2<i32>(in.texcoord.xy * texcoords_dim % texcoords_dim);
+                        let texcoords_u = vec2<i32>(varyings.texcoord.xy * texcoords_dim % texcoords_dim);
                         color_value = vec4<f32>(textureLoad(r_tex, texcoords_u, 0));
                     $$ endif
                 $$ elif texture_dim == '3d'
                     $$ if texture_format == 'f32'
-                        color_value = textureSample(r_tex, r_sampler, in.texcoord.xyz);
+                        color_value = textureSample(r_tex, r_sampler, varyings.texcoord.xyz);
                     $$ else
                         let texcoords_dim = vec3<f32>(textureDimensions(r_tex));
-                        let texcoords_u = vec3<i32>(in.texcoord.xyz * texcoords_dim % texcoords_dim);
+                        let texcoords_u = vec3<i32>(varyings.texcoord.xyz * texcoords_dim % texcoords_dim);
                         color_value = vec4<f32>(textureLoad(r_tex, texcoords_u, 0));
                     $$ endif
                 $$ endif
@@ -419,55 +396,44 @@ class MeshShader(WorldObjectShader):
                     color_value = vec4<f32>(color_value.rrr, color_value.g);
                 $$ endif
                 let albeido = (color_value.rgb - u_material.clim[0]) / (u_material.clim[1] - u_material.clim[0]);
+            $$ elif normal_color
+                let albeido = normalize(varyings.normal.xyz) * 0.5 + 0.5;
+                color_value = vec4<f32>(albeido, 1.0);
             $$ else
                 // Just a simple color
                 color_value = u_material.color;
-                let albeido: vec3<f32> = color_value.rgb;
+                let albeido = color_value.rgb;
             $$ endif
 
             // Lighting
             $$ if lighting
-                let lit_color = lighting_{{ lighting }}(is_front, in.world_pos, in.normal, in.light, in.view, albeido);
+                let world_pos = varyings.world_pos;
+                let lit_color = lighting_{{ lighting }}(is_front, varyings.world_pos, varyings.normal, varyings.light, varyings.view, albeido);
             $$ else
                 let lit_color = albeido;
             $$ endif
 
-            // Final color
-            out.color = vec4<f32>(lit_color, color_value.a);
-
-            // Picking
-            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;
-
             $$ if wireframe
-                let distance_from_edge = min(in.wireframe_coords.x, min(in.wireframe_coords.y, in.wireframe_coords.z));
+                let distance_from_edge = min(varyings.wireframe_coords.x, min(varyings.wireframe_coords.y, varyings.wireframe_coords.z));
                 if (distance_from_edge > 0.5 * u_material.wireframe) {
                     discard;
                 }
             $$ endif
 
-            apply_clipping_planes(in.world_pos);
-            return out;
-        }
-
+            let final_color = vec4<f32>(lit_color, color_value.a * u_material.opacity);
 
-        [[stage(fragment)]]
-        fn fs_normal_color(in: VertexOutput) -> FragmentOutput {
-            var out: FragmentOutput;
+            // Wrap up
 
-            // Color
-            let v = normalize(in.normal) * 0.5 + 0.5;
-            out.color = vec4<f32>(v, 1.0);
+            apply_clipping_planes(varyings.world_pos);
+            add_fragment(varyings.position.z, final_color);
+            var out = finalize_fragment();
 
-            // Picking
-            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);
+            $$ if render_pass == 1
+            let face_id = vec2<i32>(varyings.pick_idx.xz * 10000.0 + varyings.pick_idx.yw + 0.5);  // inst+face
+            let w8 = vec3<i32>(varyings.pick_coords.xyz * 255.0 + 0.5);
             out.pick = vec4<i32>(u_wobject.id, face_id, w8.x * 65536 + w8.y * 256 + w8.z);
+            $$ endif
 
-            apply_clipping_planes(in.world_pos);
             return out;
         }
         """
@@ -549,17 +515,16 @@ class MeshShader(WorldObjectShader):
 
 
 @register_wgpu_render_function(Mesh, MeshSliceMaterial)
-def meshslice_renderer(wobject, render_info):
+def meshslice_renderer(render_info):
     """Render function capable of rendering mesh slices."""
 
+    wobject = render_info.wobject
     geometry = wobject.geometry
     material = wobject.material  # noqa
 
     # Initialize
     topology = wgpu.PrimitiveTopology.triangle_list
-    shader = MeshSliceShader(wobject)
-    vs_entry_point = "vs_main"
-    fs_entry_point = "fs_main"
+    shader = MeshSliceShader(render_info)
 
     # We're assuming the presence of an index buffer for now
     assert getattr(geometry, "indices", None)
@@ -586,11 +551,9 @@ def meshslice_renderer(wobject, render_info):
         shader.define_binding(0, i, binding)
 
     # Put it together!
-    wgsl = shader.generate_wgsl()
     return [
         {
-            "vertex_shader": (wgsl, vs_entry_point),
-            "fragment_shader": (wgsl, fs_entry_point),
+            "render_shader": shader,
             "primitive_topology": topology,
             "indices": (range(n), range(n_instances)),
             "index_buffer": None,
@@ -604,38 +567,21 @@ class MeshSliceShader(WorldObjectShader):
     def get_code(self):
         return (
             self.get_definitions()
-            + self.more_definitions()
             + self.common_functions()
             + self.vertex_shader()
             + self.fragment_shader()
         )
 
-    def more_definitions(self):
+    def vertex_shader(self):
         return """
 
         struct VertexInput {
             [[builtin(vertex_index)]] vertex_index : u32;
         };
-        struct VertexOutput {
-            [[location(0)]] dist2center: vec2<f32>;
-            [[location(1)]] segment_length: f32;
-            [[location(2)]] segment_width: f32;
-            [[location(3)]] face_idx: vec4<f32>;
-            [[location(4)]] face_coords: vec3<f32>;
-            [[location(5)]] world_pos: vec3<f32>;
-            [[builtin(position)]] position: vec4<f32>;
-        };
 
-        struct FragmentOutput {
-            [[location(0)]] color: vec4<f32>;
-            [[location(1)]] pick: vec4<i32>;
-        };
-        """
 
-    def vertex_shader(self):
-        return """
         [[stage(vertex)]]
-        fn vs_main(in: VertexInput) -> VertexOutput {
+        fn vs_main(in: VertexInput) -> Varyings {
 
             // This vertex shader uses VertexId and storage buffers instead of
             // vertex buffers. It creates 6 vertices for each face in the mesh,
@@ -643,8 +589,6 @@ class MeshSliceShader(WorldObjectShader):
             // draw a (thick) line segment with round caps (we need 6 verts for that).
             // Other faces become degenerate triangles.
 
-            var out: VertexOutput;
-
             let screen_factor = u_stdinfo.logical_size.xy / 2.0;
             let l2p = u_stdinfo.physical_size.x / u_stdinfo.logical_size.x;
             let thickness = u_material.thickness;  // in logical pixels
@@ -693,10 +637,11 @@ class MeshSliceShader(WorldObjectShader):
             let pos_index = b1 + b2 + b3;
 
             // The big triage
-
             var the_pos: vec4<f32>;
             var the_coord: vec2<f32>;
             var segment_length: f32;
+            var pick_idx: vec4<f32>;
+            var pick_coords: vec4<f32>;
 
             if (pos_index < 3) {//   (pos_index < 3) {  // or dot(n, u) == 0.0
                 // Just return the same vertex, resulting in degenerate triangles
@@ -774,18 +719,21 @@ class MeshSliceShader(WorldObjectShader):
                 the_coord = the_vec * pvec_local;
 
                 // Picking info
-                out.face_idx = vec4<f32>(0.0, 0.0, f32(face_index / 10000), f32(face_index % 10000));
+                pick_idx = vec4<f32>(0.0, 0.0, f32(face_index / 10000), f32(face_index % 10000));
                 let mixval = the_vec.x * 0.5 + 0.5;
-                out.face_coords = mix(fw_a, fw_b, vec3<f32>(mixval, mixval, mixval));
+                pick_coords = vec4<f32>(mix(fw_a, fw_b, vec3<f32>(mixval, mixval, mixval)), 0.0);
             }
 
             // Shader output
-            out.world_pos = ndc_to_world_pos(the_pos);
-            out.position = the_pos;
-            out.dist2center = the_coord * l2p;
-            out.segment_length = segment_length * l2p;
-            out.segment_width = thickness * l2p;
-            return out;
+            var varyings: Varyings;
+            varyings.position = vec4<f32>(the_pos);
+            varyings.world_pos = vec3<f32>(ndc_to_world_pos(the_pos));
+            varyings.dist2center = vec2<f32>(the_coord * l2p);
+            varyings.segment_length = f32(segment_length * l2p);
+            varyings.segment_width = f32(thickness * l2p);
+            varyings.pick_idx = vec4<f32>(pick_idx);
+            varyings.pick_coords = vec4<f32>(pick_coords);
+            return varyings;
         }
         """
 
@@ -793,14 +741,14 @@ class MeshSliceShader(WorldObjectShader):
         return """
 
         [[stage(fragment)]]
-        fn fs_main(in: VertexOutput) -> FragmentOutput {
+        fn fs_main(varyings: Varyings) -> FragmentOutput {
             var out: FragmentOutput;
 
             // Discart fragments that are too far from the centerline. This makes round caps.
             // Note that we operate in physical pixels here.
-            let distx = max(0.0, abs(in.dist2center.x) - 0.5 * in.segment_length);
-            let dist = length(vec2<f32>(distx, in.dist2center.y));
-            if (dist > in.segment_width * 0.5) {
+            let distx = max(0.0, abs(varyings.dist2center.x) - 0.5 * varyings.segment_length);
+            let dist = length(vec2<f32>(distx, varyings.dist2center.y));
+            if (dist > varyings.segment_width * 0.5) {
                 discard;
             }
 
@@ -809,15 +757,20 @@ class MeshSliceShader(WorldObjectShader):
 
             // Set color
             let color = u_material.color;
-            out.color = vec4<f32>(color.rgb, min(1.0, color.a) * alpha);
+            let final_color = vec4<f32>(color.rgb, min(1.0, color.a) * alpha);
 
-            // Picking
-            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);
+            // Wrap up
+
+            apply_clipping_planes(varyings.world_pos);
+            add_fragment(varyings.position.z, final_color);
+            var out = finalize_fragment();
+
+            $$ if render_pass == 1
+            let face_id = vec2<i32>(varyings.pick_idx.xz * 10000.0 + varyings.pick_idx.yw + 0.5);
+            let w8 = vec3<i32>(varyings.pick_coords.xyz * 255.0 + 0.5);
             out.pick = vec4<i32>(u_wobject.id, face_id, w8.x * 65536 + w8.y * 256 + w8.z);
+            $$ endif
 
-            out.color.a = out.color.a * u_material.opacity;
-            apply_clipping_planes(in.world_pos);
             return out;
         }
         """
diff --git a/pygfx/renderers/wgpu/pointsrender.py b/pygfx/renderers/wgpu/pointsrender.py
index 16e2b3a..26047a3 100644
--- a/pygfx/renderers/wgpu/pointsrender.py
+++ b/pygfx/renderers/wgpu/pointsrender.py
@@ -7,13 +7,17 @@ from ...materials import PointsMaterial, GaussianPointsMaterial
 
 
 @register_wgpu_render_function(Points, PointsMaterial)
-def points_renderer(wobject, render_info):
+def points_renderer(render_info):
     """Render function capable of rendering Points."""
 
+    wobject = render_info.wobject
     geometry = wobject.geometry
     material = wobject.material
     shader = PointsShader(
-        wobject, type="circle", per_vertex_sizes=False, per_vertex_colors=False
+        render_info,
+        type="circle",
+        per_vertex_sizes=False,
+        per_vertex_colors=False,
     )
     n = geometry.positions.nitems * 6
 
@@ -47,11 +51,9 @@ def points_renderer(wobject, render_info):
         shader.define_binding(0, i, binding)
 
     # Put it together!
-    wgsl = shader.generate_wgsl()
     return [
         {
-            "vertex_shader": (wgsl, "vs_main"),
-            "fragment_shader": (wgsl, "fs_main"),
+            "render_shader": shader,
             "primitive_topology": wgpu.PrimitiveTopology.triangle_list,
             "indices": (range(n), range(1)),
             "vertex_buffers": {},
@@ -72,43 +74,21 @@ class PointsShader(WorldObjectShader):
     def get_code(self):
         return (
             self.get_definitions()
-            + self.more_definitions()
             + self.common_functions()
             + self.vertex_shader()
             + self.fragment_shader()
         )
 
-    def more_definitions(self):
+    def vertex_shader(self):
         return """
 
         struct VertexInput {
             [[builtin(vertex_index)]] vertex_index : u32;
         };
-        struct VertexOutput {
-            [[location(0)]] pointcoord: vec2<f32>;
-            [[location(1)]] vertex_idx: vec2<f32>;
-            [[location(2)]] world_pos: vec3<f32>;
-            $$ if per_vertex_sizes
-            [[location(3)]] size: f32;
-            $$ endif
-            $$ if per_vertex_colors
-            [[location(4)]] color: vec4<f32>;
-            $$ endif
-            [[builtin(position)]] position: vec4<f32>;
-        };
-
-        struct FragmentOutput {
-            [[location(0)]] color: vec4<f32>;
-            [[location(1)]] pick: vec4<i32>;
-        };
-        """
 
-    def vertex_shader(self):
-        return """
 
         [[stage(vertex)]]
-        fn vs_main(in: VertexInput) -> VertexOutput {
-            var out: VertexOutput;
+        fn vs_main(in: VertexInput) -> Varyings {
 
             let index = i32(in.vertex_index);
             let i0 = index / 6;
@@ -127,9 +107,9 @@ class PointsShader(WorldObjectShader):
                 vec2<f32>( 1.0,  1.0),
             );
 
+            // Need size here in vertex shader too
             $$ if per_vertex_sizes
                 let size = load_s_sizes(i0);
-                out.size = size;
             $$ else
                 let size = u_material.size;
             $$ endif
@@ -137,16 +117,15 @@ class PointsShader(WorldObjectShader):
             let aa_margin = 1.0;
             let delta_logical = deltas[sub_index] * (size + aa_margin);
             let delta_ndc = delta_logical * (1.0 / u_stdinfo.logical_size);
-            out.world_pos = world_pos.xyz / world_pos.w;
-            out.position = vec4<f32>(ndc_pos.xy + delta_ndc, ndc_pos.zw);
-            out.pointcoord = delta_logical;
-
-            $$ if per_vertex_colors
-            out.color = load_s_colors(i0);
-            $$ endif
 
-            out.vertex_idx = vec2<f32>(f32(i0 / 10000), f32(i0 % 10000));
-            return out;
+            var varyings: Varyings;
+            varyings.position = vec4<f32>(ndc_pos.xy + delta_ndc, ndc_pos.zw);
+            varyings.world_pos = vec3<f32>(world_pos.xyz / world_pos.w);
+            varyings.pointcoord = vec2<f32>(delta_logical);
+            varyings.size = f32(size);
+            varyings.color = vec4<f32>(load_s_colors(i0));
+            varyings.pick_idx = vec2<f32>(f32(i0 / 10000), f32(i0 % 10000));
+            return varyings;
         }
         """
 
@@ -155,31 +134,31 @@ class PointsShader(WorldObjectShader):
         return """
 
         [[stage(fragment)]]
-        fn fs_main(in: VertexOutput) -> FragmentOutput {
-            var out: FragmentOutput;
+        fn fs_main(varyings: Varyings) -> FragmentOutput {
+            var final_color : vec4<f32>;
 
-            let d = length(in.pointcoord);
+            let d = length(varyings.pointcoord);
             let aa_width = 1.0;
 
             $$ if per_vertex_sizes
-                let size = in.size;
+                let size = varyings.size;
             $$ else
                 let size = u_material.size;
             $$ endif
 
             $$ if per_vertex_colors
-                let color = in.color;
+                let color = varyings.color;
             $$ else
                 let color = u_material.color;
             $$ endif
 
             $$ if type == 'circle'
                 if (d <= size - 0.5 * aa_width) {
-                    out.color = color;
+                    final_color = color;
                 } elseif (d <= size + 0.5 * aa_width) {
                     let alpha1 = 0.5 + (size - d) / aa_width;
                     let alpha2 = pow(alpha1, 2.0);  // this works better
-                    out.color = vec4<f32>(color.rgb, color.a * alpha2);
+                    final_color = vec4<f32>(color.rgb, color.a * alpha2);
                 } else {
                     discard;
                 }
@@ -188,7 +167,7 @@ class PointsShader(WorldObjectShader):
                     let sigma = size / 3.0;
                     let t = d / sigma;
                     let a = exp(-0.5 * t * t);
-                    out.color = vec4<f32>(color.rgb, color.a * a);
+                    final_color = vec4<f32>(color.rgb, color.a * a);
                 } else {
                     discard;
                 }
@@ -196,12 +175,18 @@ class PointsShader(WorldObjectShader):
                 invalid_point_type;
             $$ endif
 
-            // Picking
-            let vertex_id = i32(in.vertex_idx.x * 10000.0 + in.vertex_idx.y + 0.5);
+            final_color.a = final_color.a * u_material.opacity;
+
+            // Wrap up
+            apply_clipping_planes(varyings.world_pos);
+            add_fragment(varyings.position.z, final_color);
+            var out = finalize_fragment();
+
+            $$ if render_pass == 1
+            let vertex_id = i32(varyings.pick_idx.x * 10000.0 + varyings.pick_idx.y + 0.5);
             out.pick = vec4<i32>(u_wobject.id, 0, vertex_id, 0);
+            $$ endif
 
-            out.color.a = out.color.a * u_material.opacity;
-            apply_clipping_planes(in.world_pos);
             return out;
         }
         """
diff --git a/pygfx/renderers/wgpu/volumerender.py b/pygfx/renderers/wgpu/volumerender.py
index b208975..780bcf3 100644
--- a/pygfx/renderers/wgpu/volumerender.py
+++ b/pygfx/renderers/wgpu/volumerender.py
@@ -2,7 +2,7 @@ import wgpu  # only for flags/enums
 
 from . import register_wgpu_render_function
 from ._shadercomposer import Binding, WorldObjectShader
-from ._conv import to_texture_format
+from ._utils import to_texture_format
 from ...objects import Volume
 from ...materials import VolumeSliceMaterial, VolumeRayMaterial
 from ...resources import Texture, TextureView
@@ -81,12 +81,13 @@ class BaseVolumeShader(WorldObjectShader):
 
 
 @register_wgpu_render_function(Volume, VolumeSliceMaterial)
-def volume_slice_renderer(wobject, render_info):
+def volume_slice_renderer(render_info):
     """Render function capable of rendering volumes."""
 
+    wobject = render_info.wobject
     geometry = wobject.geometry
     material = wobject.material  # noqa
-    shader = VolumeSliceShader(wobject, climcorrection=False)
+    shader = VolumeSliceShader(render_info, climcorrection=False)
 
     bindings = {}
 
@@ -132,11 +133,9 @@ def volume_slice_renderer(wobject, render_info):
         shader.define_binding(0, i, binding)
 
     # Put it together!
-    wgsl = shader.generate_wgsl()
     return [
         {
-            "vertex_shader": (wgsl, "vs_main"),
-            "fragment_shader": (wgsl, "fs_main"),
+            "render_shader": shader,
             "primitive_topology": topology,
             "indices": (range(n), range(1)),
             "vertex_buffers": {},
@@ -149,37 +148,22 @@ class VolumeSliceShader(BaseVolumeShader):
     def get_code(self):
         return (
             self.get_definitions()
-            + self.more_definitions()
             + self.common_functions()
             + self.volume_helpers()
             + self.vertex_shader()
             + self.fragment_shader()
         )
 
-    def more_definitions(self):
+    def vertex_shader(self):
         return """
 
         struct VertexInput {
             [[builtin(vertex_index)]] vertex_index : u32;
         };
-        struct VertexOutput {
-            [[location(0)]] texcoord: vec3<f32>;
-            [[location(1)]] world_pos: vec3<f32>;
-            [[builtin(position)]] position: vec4<f32>;
-        };
-
-        struct FragmentOutput {
-            [[location(0)]] color: vec4<f32>;
-            [[location(1)]] pick: vec4<i32>;
-        };
-        """
 
-    def vertex_shader(self):
-        return """
 
         [[stage(vertex)]]
-        fn vs_main(in: VertexInput) -> VertexOutput {
-            var out: VertexOutput;
+        fn vs_main(in: VertexInput) -> Varyings {
 
             // Our geometry is implicitly defined by the volume dimensions.
             var geo = get_vol_geometry();
@@ -318,11 +302,12 @@ class VolumeSliceShader(BaseVolumeShader):
             var indexmap = array<i32,12>(0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 5);
             let world_pos = vertices[ indexmap[index] ];
             let ndc_pos = u_stdinfo.projection_transform * u_stdinfo.cam_transform * vec4<f32>(world_pos, 1.0);
-            out.world_pos = world_pos;
-            out.position = ndc_pos;
-            out.texcoord = texcoords[ indexmap[index] ];
 
-            return out;
+            var varyings : Varyings;
+            varyings.position = vec4<f32>(ndc_pos);
+            varyings.world_pos = vec3<f32>(world_pos);
+            varyings.texcoord = vec3<f32>(texcoords[ indexmap[index] ]);
+            return varyings;
         }
         """
 
@@ -330,17 +315,21 @@ class VolumeSliceShader(BaseVolumeShader):
         return """
 
         [[stage(fragment)]]
-        fn fs_main(in: VertexOutput) -> FragmentOutput {
-            var out: FragmentOutput;
-
+        fn fs_main(varyings: Varyings) -> FragmentOutput {
             let sizef = vec3<f32>(textureDimensions(r_tex));
-            let color_value = sample(in.texcoord.xyz, sizef);
+            let color_value = sample(varyings.texcoord.xyz, sizef);
             let albeido = (color_value.rgb - u_material.clim[0]) / (u_material.clim[1] - u_material.clim[0]);
 
-            out.color = vec4<f32>(albeido, color_value.a * u_material.opacity);
-            out.pick = vec4<i32>(u_wobject.id, vec3<i32>(in.texcoord * 1048576.0 + 0.5));
+            let final_color = vec4<f32>(albeido, color_value.a * u_material.opacity);
+
+            // Wrap up
+            apply_clipping_planes(varyings.world_pos);
+            add_fragment(varyings.position.z, final_color);
+            var out = finalize_fragment();
 
-            apply_clipping_planes(in.world_pos);
+            $$ if render_pass == 1
+            out.pick = vec4<i32>(u_wobject.id, vec3<i32>(varyings.texcoord * 1048576.0 + 0.5));
+            $$ endif
             return out;
         }
 
@@ -348,12 +337,15 @@ class VolumeSliceShader(BaseVolumeShader):
 
 
 @register_wgpu_render_function(Volume, VolumeRayMaterial)
-def volume_ray_renderer(wobject, render_info):
+def volume_ray_renderer(render_info):
     """Render function capable of rendering volumes."""
 
+    wobject = render_info.wobject
     geometry = wobject.geometry
     material = wobject.material  # noqa
-    shader = VolumeRayShader(wobject, mode=material.render_mode, climcorrection=False)
+    shader = VolumeRayShader(
+        render_info, mode=material.render_mode, climcorrection=False
+    )
 
     bindings = {}
 
@@ -396,11 +388,9 @@ def volume_ray_renderer(wobject, render_info):
         shader.define_binding(0, i, binding)
 
     # Put it together!
-    wgsl = shader.generate_wgsl()
     return [
         {
-            "vertex_shader": (wgsl, "vs_main"),
-            "fragment_shader": (wgsl, "fs_main"),
+            "render_shader": shader,
             "primitive_topology": wgpu.PrimitiveTopology.triangle_list,
             "cull_mode": wgpu.CullMode.front,  # the back planes are the ref
             "indices": (range(36), range(1)),
@@ -414,7 +404,6 @@ class VolumeRayShader(BaseVolumeShader):
     def get_code(self):
         return (
             self.get_definitions()
-            + self.more_definitions()
             + self.common_functions()
             + self.volume_helpers()
             + self.render_function()
@@ -422,38 +411,15 @@ class VolumeRayShader(BaseVolumeShader):
             + self.fragment_shader()
         )
 
-    def more_definitions(self):
+    def vertex_shader(self):
         return """
 
         struct VertexInput {
             [[builtin(vertex_index)]] vertex_index : u32;
         };
-        struct VertexOutput {
-            [[location(0)]] world_pos: vec4<f32>;
-            [[location(1)]] data_back_pos: vec4<f32>;
-            [[location(2)]] data_near_pos: vec4<f32>;
-            [[location(3)]] data_far_pos: vec4<f32>;
-            [[builtin(position)]] position: vec4<f32>;
-        };
-
-        struct RenderOutput {
-            color: vec4<f32>;
-            coord: vec3<f32>;
-        };
-
-        struct FragmentOutput {
-            [[location(0)]] color: vec4<f32>;
-            [[location(1)]] pick: vec4<i32>;
-            [[builtin(frag_depth)]] depth : f32;
-        };
-        """
-
-    def vertex_shader(self):
-        return """
 
         [[stage(vertex)]]
-        fn vs_main(in: VertexInput) -> VertexOutput {
-            var out: VertexOutput;
+        fn vs_main(in: VertexInput) -> Varyings {
 
             // Our geometry is implicitly defined by the volume dimensions.
             var geo = get_vol_geometry();
@@ -467,20 +433,22 @@ class VolumeRayShader(BaseVolumeShader):
             let world_pos = u_wobject.world_transform * data_pos;
             let ndc_pos = u_stdinfo.projection_transform * u_stdinfo.cam_transform * world_pos;
 
-            // Store values for fragment shader
-            out.world_pos = world_pos;
-            out.position = ndc_pos;
-
             // Prepare inverse matrix
             let ndc_to_data = u_wobject.world_transform_inv * u_stdinfo.cam_transform_inv * u_stdinfo.projection_transform_inv;
 
+            var varyings: Varyings;
+
+            // Store values for fragment shader
+            varyings.position = vec4<f32>(ndc_pos);
+            varyings.world_pos = vec3<f32>(world_pos);
+
             // The position on the face of the cube. We can say that it's the back face,
             // because we cull the front faces.
             // These positions are in data positions (voxels) rather than texcoords (0..1),
             // because distances make more sense in this space. In the fragment shader we
             // can consider it an isotropic volume, because any position, rotation,
             // and scaling of the volume is part of the world transform.
-            out.data_back_pos = data_pos;
+            varyings.data_back_pos = vec4<f32>(data_pos);
 
             // We calculate the NDC positions for the near and front clipping planes,
             // and transform these back to data coordinates. From these positions
@@ -490,18 +458,17 @@ class VolumeRayShader(BaseVolumeShader):
             // Note that the w component for these positions should be left intact.
             let ndc_pos1 = vec4<f32>(ndc_pos.xy, -ndc_pos.w, ndc_pos.w);
             let ndc_pos2 = vec4<f32>(ndc_pos.xy, ndc_pos.w, ndc_pos.w);
-            out.data_near_pos = ndc_to_data * ndc_pos1;
-            out.data_far_pos = ndc_to_data * ndc_pos2;
+            varyings.data_near_pos = vec4<f32>(ndc_to_data * ndc_pos1);
+            varyings.data_far_pos = vec4<f32>(ndc_to_data * ndc_pos2);
 
-            return out;
+            return varyings;
         }
         """
 
     def fragment_shader(self):
         return """
-
         [[stage(fragment)]]
-        fn fs_main(in: VertexOutput) -> FragmentOutput {
+        fn fs_main(varyings: Varyings) -> FragmentOutput {
 
             // Get size of the volume
             let sizef = vec3<f32>(textureDimensions(r_tex));
@@ -516,9 +483,9 @@ class VolumeRayShader(BaseVolumeShader):
             let relative_step_size = clamp(sqrt(max(sizef.x, max(sizef.y, sizef.z))) / 20.0, 0.1, 0.8);
 
             // Positions in data coordinates
-            let back_pos = in.data_back_pos.xyz / in.data_back_pos.w;
-            let far_pos = in.data_far_pos.xyz / in.data_far_pos.w;
-            let near_pos = in.data_near_pos.xyz / in.data_near_pos.w;
+            let back_pos = varyings.data_back_pos.xyz / varyings.data_back_pos.w;
+            let far_pos = varyings.data_far_pos.xyz / varyings.data_far_pos.w;
+            let near_pos = varyings.data_near_pos.xyz / varyings.data_near_pos.w;
 
             // Calculate unit vector pointing in the view direction through this fragment.
             let view_ray = normalize(far_pos - near_pos);
@@ -554,11 +521,13 @@ class VolumeRayShader(BaseVolumeShader):
             // Maybe we did the work for nothing
             apply_clipping_planes(world_pos.xyz);
 
-            // Pack up!
-            var out : FragmentOutput;
-            out.color = render_out.color;
+            add_fragment(varyings.position.z, render_out.color);
+            var out = finalize_fragment();
             out.depth = ndc_pos.z / ndc_pos.w;
+
+            $$ if render_pass == 1
             out.pick = vec4<i32>(u_wobject.id, vec3<i32>(render_out.coord * 1048576.0 + 0.5));
+            $$ endif
             return out;
         }
         """
@@ -566,7 +535,15 @@ class VolumeRayShader(BaseVolumeShader):
     def render_function(self):
         # Triage over different render modes. Only one mode so far :)
         f = getattr(self, "render_mode_" + self.kwargs["mode"].lower(), "mip")
-        return f()
+
+        preamble = """
+        struct RenderOutput {
+            color: vec4<f32>;
+            coord: vec3<f32>;
+        };
+        """
+
+        return preamble + f()
 
     def render_mode_mip(self):
 


Test patch:
diff --git a/tests/renderers/test_render_registry.py b/tests/renderers/test_render_registry.py
index 926b7e8..9be786c 100644
--- a/tests/renderers/test_render_registry.py
+++ b/tests/renderers/test_render_registry.py
@@ -157,7 +157,7 @@ def test_render_registry_selection():
 
 
 def test_render_registry_of_wgpu():
-    r = gfx.renderers.wgpu._wgpurenderer.registry
+    r = gfx.renderers.wgpu.registry
 
     assert None is r.get_render_function(Object1(Material1()))
 
diff --git a/tests/renderers/test_resolve_varyings.py b/tests/renderers/test_resolve_varyings.py
new file mode 100644
index 0000000..cce5a09
--- /dev/null
+++ b/tests/renderers/test_resolve_varyings.py
@@ -0,0 +1,637 @@
+from pygfx.renderers.wgpu._shadercomposer import resolve_varyings
+from pytest import raises
+
+
+def test_varying_basics():
+
+    assert resolve_varyings("") == ""
+    assert resolve_varyings("foo") == "foo\n"
+    assert resolve_varyings("\nfoo\n") == "foo\n"
+    assert resolve_varyings("\n\nfoo\n\n") == "foo\n"
+    assert resolve_varyings("\n\n    foo\n\n") == "    foo\n"
+
+
+def test_varyings_struct_position0():
+    # If no varyings are used, the struct is not inserted
+
+    code1 = """
+    fn vs_main() {
+    }
+    fn fs_main() {
+    }
+    """
+
+    code2 = code1
+
+    code3 = resolve_varyings(code1)
+    assert code3.strip() == code2.strip()
+
+
+def test_varyings_struct_position1():
+    # But if the struct type is used, we insert it
+
+    code1 = """
+    fn vs_main() -> Varyings {
+    }
+    fn fs_main(varyings : Varyings) {
+    }
+    """
+
+    code2 = """
+    struct Varyings {
+    };
+
+    fn vs_main() -> Varyings {
+    }
+    fn fs_main(varyings : Varyings) {
+    }
+    """
+
+    code3 = resolve_varyings(code1)
+    assert code3.strip() == code2.strip()
+
+
+def test_varyings_struct_position2():
+    # Indentation is preserved
+
+    code1 = """
+            fn vs_main() -> Varyings {
+            }
+            fn fs_main(varyings : Varyings) {
+            }
+    """
+
+    code2 = """
+            struct Varyings {
+            };
+
+            fn vs_main() -> Varyings {
+            }
+            fn fs_main(varyings : Varyings) {
+            }
+    """
+
+    code3 = resolve_varyings(code1)
+    assert code3.strip() == code2.strip()
+
+
+def test_varyings_struct_position3():
+    # Struct is positioned before first function that uses it
+
+    code1 = """
+    fn foo() = {
+    }
+    fn vs_main() -> Varyings {
+    }
+    fn fs_main(varyings : Varyings) {
+    }
+    """
+
+    code2 = """
+    fn foo() = {
+    }
+    struct Varyings {
+    };
+
+    fn vs_main() -> Varyings {
+    }
+    fn fs_main(varyings : Varyings) {
+    }
+    """
+
+    code3 = resolve_varyings(code1)
+    assert code3.strip() == code2.strip()
+
+
+def test_varyings_struct_position4():
+    # Or at the start if no function was detected
+
+    code1 = """
+    let x = 3;
+    var y : Varyings;
+    """
+
+    code2 = """
+    struct Varyings {
+    };
+
+    let x = 3;
+    var y : Varyings;
+    """
+
+    code3 = resolve_varyings(code1)
+    assert code3.strip() == code2.strip()
+
+
+def test_varyings_struct_position5():
+    # Positioning keeps entrypoint stuff into account
+
+    code1 = """
+    fn foo() = {
+    }
+    [[blabla]]
+    fn vs_main() -> Varyings {
+    }
+    fn fs_main(varyings : Varyings) {
+    }
+    """
+
+    code2 = """
+    fn foo() = {
+    }
+    struct Varyings {
+    };
+
+    [[blabla]]
+    fn vs_main() -> Varyings {
+    }
+    fn fs_main(varyings : Varyings) {
+    }
+    """
+
+    code3 = resolve_varyings(code1)
+    assert code3.strip() == code2.strip()
+
+
+def test_varyings_remove1():
+    # If no varyings are used, any set varyings are removed.
+
+    code1 = """
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.foo = f32(something1);
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+    }
+    """
+
+    code2 = """
+    struct Varyings {
+    };
+
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        // unused: varyings.foo = f32(something1);
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+    }
+    """
+
+    code3 = resolve_varyings(code1)
+    assert code3.strip() == code2.strip()
+
+
+def test_varyings_remove2():
+    # Used varyings are held
+
+    code1 = """
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.foo = f32(something1);
+        varyings.bar = vec2<f32>(something2);
+        varyings.spam = vec3<f32>(something3);
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+        use(varyings.bar);
+    }
+    """
+
+    code2 = """
+    struct Varyings {
+        [[location(0)]] bar : vec2<f32>;
+    };
+
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        // unused: varyings.foo = f32(something1);
+        varyings.bar = vec2<f32>(something2);
+        // unused: varyings.spam = vec3<f32>(something3);
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+        use(varyings.bar);
+    }
+    """
+
+    code3 = resolve_varyings(code1)
+    assert code3.strip() == code2.strip()
+
+
+def test_varyings_remove3():
+    # Comments are taken into account
+
+    code1 = """
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.foo = f32(something1);
+        varyings.bar = vec2<f32>(something2);
+        varyings.spam = vec3<f32>(something3);
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+        // use(varyings.bar, varyings.foo);
+        // let x = varyings.spam;
+    }
+    """
+
+    code2 = """
+    struct Varyings {
+    };
+
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        // unused: varyings.foo = f32(something1);
+        // unused: varyings.bar = vec2<f32>(something2);
+        // unused: varyings.spam = vec3<f32>(something3);
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+        // use(varyings.bar, varyings.foo);
+        // let x = varyings.spam;
+    }
+    """
+
+    code3 = resolve_varyings(code1)
+    assert code3.strip() == code2.strip()
+
+
+def test_varyings_remove4():
+    # Usage can actually be to the left of the assignment op
+
+    code1 = """
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.foo = f32(something1);
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+        something[i32(varyings.foo)] = 0.0;
+    }
+    """
+
+    code2 = """
+    struct Varyings {
+        [[location(0)]] foo : f32;
+    };
+
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.foo = f32(something1);
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+        something[i32(varyings.foo)] = 0.0;
+    }
+    """
+
+    code3 = resolve_varyings(code1)
+    assert code3.strip() == code2.strip()
+
+
+def test_varyings_remove5():
+    # Varying assignments can be multi-line
+
+    code1 = """
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.spam = vec3<f32>(
+            1.0, 2.0, 3.0
+        );
+        return varyings;
+    }
+    """
+
+    code2 = """
+    struct Varyings {
+    };
+
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        // unused: varyings.spam = vec3<f32>(
+        // 1.0, 2.0, 3.0
+        // );
+        return varyings;
+    }
+    """
+
+    code3 = resolve_varyings(code1)
+    assert code3.strip() == code2.strip()
+
+
+def test_varyings_struct1():
+    # Usage in other funcs counts, and varyings are sorted alphabetically
+
+    code1 = """
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.foo = f32(something1);
+        varyings.bar = vec2<f32>(something2);
+        varyings.spam = vec3<f32>(something3);
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+        use(varyings.foo,varyings.bar);
+    }
+
+    fn func(varyings: Varyings) {
+        let x = varyings.spam + 2.0;
+    }
+    """
+
+    code2 = """
+    struct Varyings {
+        [[location(0)]] bar : vec2<f32>;
+        [[location(1)]] foo : f32;
+        [[location(2)]] spam : vec3<f32>;
+    };
+
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.foo = f32(something1);
+        varyings.bar = vec2<f32>(something2);
+        varyings.spam = vec3<f32>(something3);
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+        use(varyings.foo,varyings.bar);
+    }
+
+    fn func(varyings: Varyings) {
+        let x = varyings.spam + 2.0;
+    }
+    """
+
+    code3 = resolve_varyings(code1)
+    assert code3.strip() == code2.strip()
+
+
+def test_varyings_attr1():
+    # Can set varying attribute
+
+    code1 = """
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.color = vec4<f32>(color);
+        varyings.color.a = 1.0;
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+        varyings.color;
+    }
+    """
+
+    code2 = """
+    struct Varyings {
+        [[location(0)]] color : vec4<f32>;
+    };
+
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.color = vec4<f32>(color);
+        varyings.color.a = 1.0;
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+        varyings.color;
+    }
+    """
+
+    code3 = resolve_varyings(code1)
+    assert code3.strip() == code2.strip()
+
+
+def test_varyings_attr2():
+    # Can set varying attribute, and all setter lines are commented
+
+    code1 = """
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.color = vec4<f32>(color);
+        varyings.color.a = 1.0;
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+    }
+    """
+
+    code2 = """
+    struct Varyings {
+    };
+
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        // unused: varyings.color = vec4<f32>(color);
+        // unused: varyings.color.a = 1.0;
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+    }
+    """
+
+    code3 = resolve_varyings(code1)
+    assert code3.strip() == code2.strip()
+
+
+def test_varyings_builtin1():
+    # Builtins are not slots
+
+    code1 = """
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.position = vec4<f32>(ndc_pos);
+        varyings.foo = f32(something1);
+        varyings.bar = vec2<f32>(something2);
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+        use(varyings.foo,varyings.bar);
+        let pos = varyings.position;
+    }
+    """
+
+    code2 = """
+    struct Varyings {
+        [[location(0)]] bar : vec2<f32>;
+        [[location(1)]] foo : f32;
+        [[builtin(position)]] position : vec4<f32>;
+    };
+
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.position = vec4<f32>(ndc_pos);
+        varyings.foo = f32(something1);
+        varyings.bar = vec2<f32>(something2);
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+        use(varyings.foo,varyings.bar);
+        let pos = varyings.position;
+    }
+    """
+
+    code3 = resolve_varyings(code1)
+    assert code3.strip() == code2.strip()
+
+
+def test_varyings_builtin2():
+    # Builtins are considered used when set
+
+    code1 = """
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.position = vec4<f32>(ndc_pos);
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+    }
+    """
+
+    code2 = """
+    struct Varyings {
+        [[builtin(position)]] position : vec4<f32>;
+    };
+
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.position = vec4<f32>(ndc_pos);
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+    }
+    """
+
+    code3 = resolve_varyings(code1)
+    assert code3.strip() == code2.strip()
+
+
+def test_varyings_error_readonly_in_vs1():
+    # Cannot use varying as a getter in vs_main
+
+    code1 = """
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.position = vec4<f32>(ndc_pos);
+        varyings.ndc = vec3<f32>(varyings.position.xyz);
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+    }
+    """
+
+    with raises(TypeError) as info:
+        resolve_varyings(code1)
+    info.match("only writing is allowed")
+
+
+def test_varyings_error_need_type_when_set():
+    # Assignment needs type annotation
+
+    code1 = """
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.position = ndc_pos;
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+    }
+    """
+
+    with raises(TypeError) as info:
+        resolve_varyings(code1)
+    info.match("type")
+
+
+def test_varyings_error_assignments_invalid_multiline1():
+    # Assignment needs type annotation
+
+    code1 = """
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.foo = vec4<f32>(
+    """.strip()
+
+    with raises(TypeError) as info:
+        resolve_varyings(code1)
+    info.match("missing a semicolon")
+
+    code = """
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.foo = vec4<f32>(
+            1.0, 2.0, 3.0, 4.0
+        )
+        XX
+    """
+
+    for xx in ("", "}", "let y = 1;", "var : f32;"):
+        code1 = code.replace("XX", xx)
+        with raises(TypeError) as info:
+            resolve_varyings(code1)
+        info.match("missing a semicolon")
+
+
+def test_varyings_error_need_type_match():
+    # When assigning multiple times, the type should match
+
+    code1 = """
+    fn vs_main() -> Varyings {
+        var varyings : Varyings;
+        varyings.position = vec4<f32>(ndc_pos);
+        varyings.position = vec3<f32>(ndc_pos);
+        return varyings;
+    }
+
+    fn fs_main(varyings : Varyings) {
+    }
+    """
+
+    with raises(TypeError) as info:
+        resolve_varyings(code1)
+    info.match("expected type")
+
+
+def test_varyings_error_used_but_not_assigned():
+    # When assigning multiple times, the type should match
+
+    code1 = """
+    fn vs_main() -> Varyings {
+    }
+
+    fn fs_main(varyings : Varyings) {
+        varyings.foo
+    }
+    """
+
+    with raises(TypeError) as info:
+        resolve_varyings(code1)
+    info.match("not assigned")
+
+
+if __name__ == "__main__":
+    for f in list(globals().values()):
+        if callable(f) and f.__name__.startswith("test_"):
+            print(f.__name__)
+            f()
diff --git a/tests/renderers/test_shader_composer.py b/tests/renderers/test_shader_composer.py
index 5a579ae..f309719 100644
--- a/tests/renderers/test_shader_composer.py
+++ b/tests/renderers/test_shader_composer.py
@@ -121,6 +121,42 @@ def test_uniform_definitions():
         shader.define_binding(0, 0, Binding("zz", "buffer/uniform", struct))
 
 
+def test_resolve_depth_output():
+    resolve_depth_output = shadercomposer.resolve_depth_output
+
+    code1 = """
+    struct FragmentOutput {
+        [[location(0)]] color: vec4<f32>;
+    }
+    fn fs_main() {
+    }
+    """.strip()
+    code2 = code1
+    assert resolve_depth_output(code1).strip() == code2
+
+    code1 = """
+    struct FragmentOutput {
+        [[location(0)]] color: vec4<f32>;
+    }
+    fn fs_main() {
+        out.depth = 0.0;
+    }
+    """
+
+    code2 = """
+    struct FragmentOutput {
+        [[builtin(frag_depth)]] depth : f32;
+        [[location(0)]] color: vec4<f32>;
+    }
+    fn fs_main() {
+        out.depth = 0.0;
+    }
+    """.strip()
+
+    assert resolve_depth_output(code1).strip() == code2
+
+
 if __name__ == "__main__":
     test_templating()
     test_uniform_definitions()
+    test_resolve_depth_output()