Repository: pygfx/pygfx
Pull request #204 (2021-11-23 22:26:41)

Weighted blended OIT
Step 2 in #196.

* [x] Add (oprional) render-pass to combine the renderpasses.
* [x] Weighted blended without depth component in weights.
* [x] Weighted blended by depth.
* [x] Put RenderFlusher back in its own module.
* [x] Refactor to put Passes in small classes. 
* [x] Clean up.


almarklein: Ready!

This implements the McGuire weighted blending. Unfortunately, I also did some refactoring, so the diff is awful. If you want to have a look, you better read the final `_blender.py`

Here the two new methods in action: First the normal blended, fullly OIT. Next weighted blended - you can see how the colors blend depending on depth.

![2021-11-24 20:12:10 268885002](https://user-images.githubusercontent.com/3015475/143307072-7c091d29-b399-414d-89db-05781e2b9170.gif)



Korijn: It looks amazing!! Awesome! Good to merge and press on from my end... the refactoring all makes sense.

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8e952c2..7ca6683 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -70,6 +70,9 @@ jobs:
           - name: Test Linux py39
             os: ubuntu-latest
             pyversion: '3.9'
+          - name: Test Linux py310
+            os: ubuntu-latest
+            pyversion: '3.10'
     steps:
     - uses: actions/checkout@v2
     - name: Set up Python ${{ matrix.pyversion }}
diff --git a/examples/transparency1.py b/examples/transparency1.py
index e46b914..e413c65 100644
--- a/examples/transparency1.py
+++ b/examples/transparency1.py
@@ -19,7 +19,7 @@ class WgpuCanvasWithInputEvents(WgpuCanvas):
             scene.add(scene.children[0])
             canvas.request_draw()
         elif event.text() in "0123456789":
-            m = [None, "opaque", "simple1", "simple2"]
+            m = [None, "opaque", "simple1", "simple2", "blended", "weighted"]
             mode = m[int(event.text())]
             renderer.blend_mode = mode
             print("Selecting blend_mode", mode)
diff --git a/examples/transparency2.py b/examples/transparency2.py
index 3cb3134..39cac73 100644
--- a/examples/transparency2.py
+++ b/examples/transparency2.py
@@ -39,7 +39,7 @@ class WgpuCanvasWithInputEvents(WgpuCanvas):
             print("Rotating scene element order")
             scene.add(scene.children[0])
         elif event.text() in "0123456789":
-            m = [None, "opaque", "simple1", "simple2"]
+            m = [None, "opaque", "simple1", "simple2", "blended", "weighted"]
             mode = m[int(event.text())]
             renderer.blend_mode = mode
             print("Selecting blend_mode", mode)
diff --git a/pygfx/renderers/wgpu/_blender.py b/pygfx/renderers/wgpu/_blender.py
index 0a30342..daea603 100644
--- a/pygfx/renderers/wgpu/_blender.py
+++ b/pygfx/renderers/wgpu/_blender.py
@@ -1,64 +1,57 @@
 import wgpu  # only for flags/enums
 
-from ...utils import array_from_shadertype
-from ._shadercomposer import Binding, BaseShader
+from ._flusher import FULL_QUAD_SHADER, _create_pipeline
 
 
-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.
-    """
+# %%%%%%%%%%  Define passes
 
-    def __init__(self):
 
-        # The size (2D in pixels) of the frame textures.
-        self.size = (0, 0)
-        self.msaa = 1
+class BasePass:
+    """The base pass class, defining and documenting the API that a pass must provide."""
 
-        # 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 get_pipeline_targets(self, blender):
+        """Get the list of fragment targets for device.create_render_pipeline()."""
+        # The result affects the wobject's pipeline.
+        # Returning [] prevents this pipeline from being created.
+        return []
 
-    def ensure_target_size(self, device, size):
-        """If necessary, resize render buffers/textures to match the target size."""
+    def get_color_attachments(self, blender, clear):
+        """Get the list of color_attachments for command_encoder.begin_render_pass()."""
+        # The result affects the rendering, but not the wobject's pipeline.
+        # Returning [] prevents this pass from running.
+        return []
 
-        # 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 ...
+    def get_shader_code(self, blender):
+        """Get the fragment-write shader code.
 
-        assert len(size) == 2
-        size = size[0], size[1]
-        if size == self.size:
-            return
+        Notes:
 
-        self.size = size
-        tex_size = size + (1,)
-        usg = wgpu.TextureUsage
+        * 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.
+        """
+        return ""
 
-        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.
-        """
+class NullPass(BasePass):
+    """An empty (unused) pass."""
+
+
+class OpaquePass(BasePass):
+    """A pass that renders opaque fragments with depth testing, while
+    discarting transparent fragments. This functions as the first pass
+    in all mult-pass blenders.
+    """
+
+    def get_pipeline_targets(self, blender):
         return [
             {
-                "format": self.color_format,
+                "format": blender.color_format,
                 "blend": {
                     "alpha": (
                         wgpu.BlendFactor.one,
@@ -74,18 +67,14 @@ class BaseFragmentBlender:
                 "write_mask": wgpu.ColorWrite.ALL,
             },
             {
-                "format": self.pick_format,
+                "format": blender.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:
+    def get_color_attachments(self, blender, clear):
+        if clear:
             color_load_value = 0, 0, 0, 0
             pick_load_value = 0, 0, 0, 0
         else:
@@ -94,113 +83,77 @@ class BaseFragmentBlender:
 
         return [
             {
-                "view": self.color_view,
+                "view": blender.color_view,
                 "resolve_target": None,
                 "load_value": color_load_value,
                 "store_op": wgpu.StoreOp.store,
             },
             {
-                "view": self.pick_view,
+                "view": blender.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.
+    def get_shader_code(self, blender):
         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>) {
+        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 {
-                var out : FragmentOutput;
-                out.color = p_fragment_color;
-                return out;
-            }
-
-        $$ endif
+        }
+        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;
+        }
         """
 
 
-class OpaqueFragmentBlender(BaseFragmentBlender):
-    """A fragment blender that pretends that all surfaces are opaque,
-    even if they're not.
-    """
+class FullOpaquePass(OpaquePass):
+    """A pass that considers all fragments opaque."""
 
-    def get_shader_code(self):
+    def get_shader_code(self, blender):
         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;
+        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;
             }
-
-        $$ endif
+        }
+        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;
+        }
         """
 
 
-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.
-    """
+class SimpleSinglePass(OpaquePass):
+    """A pass that blends opaque and transparent fragments in a single pass."""
 
-    def get_pipeline_targets1(self):
+    def get_pipeline_targets(self, blender):
         return [
             {
-                "format": self.color_format,
+                "format": blender.color_format,
                 "blend": {
                     "alpha": (
                         wgpu.BlendFactor.one,
@@ -216,51 +169,46 @@ class Simple1FragmentBlender(BaseFragmentBlender):
                 "write_mask": wgpu.ColorWrite.ALL,
             },
             {
-                "format": self.pick_format,
+                "format": blender.pick_format,
                 "blend": None,
                 "write_mask": wgpu.ColorWrite.ALL,
             },
         ]
 
-    def get_shader_code(self):
+    def get_shader_code(self, blender):
+        # Take depth into account, but don't treat transparent fragments differently
         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;
+        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;
             }
-
-        $$ endif
+        }
+        fn finalize_fragment() -> FragmentOutput {
+            if (p_fragment_depth > 1.0) { discard; }
+            var out : FragmentOutput;
+            out.color = p_fragment_color;
+            return out;
+        }
         """
 
 
-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.
+class SimpleSecondPass(BasePass):
+    """A pass that only renders transparent fragments, blending them
+    with the classic OVER operator.
     """
 
-    def get_pipeline_targets2(self):
+    def get_pipeline_targets(self, blender):
         return [
             {
-                "format": self.color_format,
+                "format": blender.color_format,
                 "blend": {
                     "alpha": (
                         wgpu.BlendFactor.one,
@@ -277,225 +225,255 @@ class Simple2FragmentBlender(BaseFragmentBlender):
             },
         ]
 
-    def get_color_attachments2(self, clear_color):
+    def get_color_attachments(self, blender, clear):
         return [
             {
-                "view": self.color_view,
+                "view": blender.color_view,
                 "resolve_target": None,
                 "load_value": wgpu.LoadOp.load,
                 "store_op": wgpu.StoreOp.store,
             },
         ]
 
-    def get_shader_code(self):
+    def get_shader_code(self, blender):
         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;
+        }
+        """
 
-            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
-        """
+class McGuirePass(BasePass):
+    """A pass that implements weighted blended order-independed
+    blending for transparent fragments.
+    """
 
+    def __init__(self, weight_func):
+
+        if weight_func == "none":
+            weight_code = """
+                let weight = 1.0;
+            """
+        elif weight_func == "alpha":
+            weight_code = """
+                let weight = alpha;
+            """
+        elif weight_func == "depth":
+            # The "generic purpose" depth function proposed by McGuire in
+            # http://casual-effects.blogspot.com/2015/03/implemented-weighted-blended-order.html
+            weight_code = """
+                let a = min(1.0, alpha) * 8.0 + 0.01;
+                let b = (1.0 - 0.99999 * depth);
+                let weight = clamp(a * a * a * 1e8 * b * b * b, 1e-2, 3e2);
+            """
+        else:
+            raise ValueError(f"Unknown McGuirePass weight_func: {weight_func!r}")
 
-# 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.
+        self._weight_code = weight_code.strip()
 
+    def get_pipeline_targets(self, blender):
+        return [
+            {
+                "format": blender.accum_format,
+                "blend": {
+                    "alpha": (
+                        wgpu.BlendFactor.one,
+                        wgpu.BlendFactor.one,
+                        wgpu.BlendOperation.add,
+                    ),
+                    "color": (
+                        wgpu.BlendFactor.one,
+                        wgpu.BlendFactor.one,
+                        wgpu.BlendOperation.add,
+                    ),
+                },
+                "write_mask": wgpu.ColorWrite.ALL,
+            },
+            {
+                "format": blender.reveal_format,
+                "blend": {
+                    "alpha": (
+                        wgpu.BlendFactor.one,
+                        wgpu.BlendFactor.one_minus_src,
+                        wgpu.BlendOperation.add,
+                    ),
+                    "color": (
+                        wgpu.BlendFactor.zero,
+                        wgpu.BlendFactor.one_minus_src,
+                        wgpu.BlendOperation.add,
+                    ),
+                },
+                "write_mask": wgpu.ColorWrite.ALL,
+            },
+        ]
 
-class FinalShader(BaseShader):
-    """The shader for the final render step (the flushing to a texture)."""
+    def get_color_attachments(self, blender, clear):
+        if clear:
+            accum_load_value = 0, 0, 0, 0
+            reveal_load_value = 1, 0, 0, 0
+        else:
+            accum_load_value = wgpu.LoadOp.load
+            reveal_load_value = wgpu.LoadOp.load
 
-    def __init__(self):
-        super().__init__()
-        self["tex_coord_map"] = ""
-        self["color_map"] = ""
+        return [
+            {
+                "view": blender.accum_view,
+                "resolve_target": None,
+                "load_value": accum_load_value,
+                "store_op": wgpu.StoreOp.store,
+            },
+            {
+                "view": blender.reveal_view,
+                "resolve_target": None,
+                "load_value": reveal_load_value,
+                "store_op": wgpu.StoreOp.store,
+            },
+        ]
 
-    def get_code(self):
-        return (
-            self.get_definitions()
-            + """
+    def get_shader_code(self, blender):
+        return """
+        var<private> p_fragment_accum : vec4<f32> = vec4<f32>(0.0, 0.0, 0.0, 0.0);
+        var<private> p_fragment_reveal : f32 = 0.0;
 
-        struct VertexOutput {
-            [[location(0)]] texcoord: vec2<f32>;
-            [[builtin(position)]] position: vec4<f32>;
+        struct FragmentOutput {
+            [[location(0)]] accum: vec4<f32>;
+            [[location(1)]] reveal: 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;
+        fn add_fragment(depth: f32, color: vec4<f32>) {
+            let premultiplied = color.rgb * color.a;
+            let alpha = color.a;  // could take transmittance into account here
+            WEIGHT_CODE
+            p_fragment_accum = vec4<f32>(premultiplied, alpha) * weight;
+            p_fragment_reveal = alpha;
         }
-
-        [[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 }}
+        fn finalize_fragment() -> FragmentOutput {
+            if (p_fragment_reveal <= 0.0) { discard; }
+            var out : FragmentOutput;
+            out.accum = p_fragment_accum;
+            out.reveal = p_fragment_reveal;
             return out;
         }
-    """
+        """.replace(
+            "WEIGHT_CODE", self._weight_code
         )
 
 
-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
+# %%%%%%%%%% Define blenders
 
-    uniform_type = dict(
-        size="2xf4",
-        sigma="f4",
-        support="i4",
-    )
 
-    def __init__(self, device):
-        self._shader = FinalShader()
-        self._device = device
-        self._pipelines = {}
+class BaseFragmentBlender:
+    """Manage how fragments are blended and end up in the final target.
+    Each renderer has one blender object.
+    """
 
-        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,
-        )
+    pass1 = NullPass()
+    pass2 = NullPass()
 
-        self._sampler = self._device.create_sampler(
-            label="render sampler",
-            mag_filter="nearest",
-            min_filter="nearest",
-        )
+    def __init__(self):
 
-    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
+        # The size (2D in pixels) of the frame textures.
+        self.size = (0, 0)
 
-        self._update_uniforms(src_color_tex, dst_color_tex)
-        self._render(dst_color_tex, dst_format)
+        # Multi-sample anti-aliasing sample count.
+        self.msaa = 1
 
-    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
+        # Pipeline objects
+        self._combine_pass_info = None
 
-        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
+        # A dict that contains the metadata for all render targets.
+        self._texture_info = {}
 
-        self._uniform_data["size"] = src_color_tex.size[:2]
-        self._uniform_data["sigma"] = sigma
-        self._uniform_data["support"] = support
+        # The below targets are always present, and the renderer expects their
+        # format, texture, and view to be present.
 
-    def _render(self, dst_color_tex, dst_format):
-        device = self._device
-        _, bind_group, render_pipeline = self._pipelines[dst_format]
+        usg = wgpu.TextureUsage
 
-        command_encoder = device.create_command_encoder()
+        # The color texture is rgba8 unorm - not srgb, that's only for the last step.
+        self._texture_info["color"] = (
+            wgpu.TextureFormat.rgba8unorm,
+            usg.RENDER_ATTACHMENT | usg.COPY_SRC | usg.TEXTURE_BINDING,
+        )
 
-        tmp_buffer = device.create_buffer_with_data(
-            data=self._uniform_data,
-            usage=wgpu.BufferUsage.COPY_SRC,
+        # The depth buffer is 32 bit - we need that precision.
+        self._texture_info["depth"] = (
+            wgpu.TextureFormat.depth32float,
+            usg.RENDER_ATTACHMENT | usg.COPY_SRC,
         )
-        command_encoder.copy_buffer_to_buffer(
-            tmp_buffer, 0, self._uniform_buffer, 0, self._uniform_data.nbytes
+
+        # The pick texture has 4 channels: object id, and then 3 more, e.g.
+        # the instance nr, vertex nr and weights.
+        self._texture_info["pick"] = (
+            wgpu.TextureFormat.rgba32sint,
+            usg.RENDER_ATTACHMENT | usg.COPY_SRC,
         )
 
+    def ensure_target_size(self, device, size):
+        """If necessary, resize render-textures to match the target size."""
+
+        assert len(size) == 2
+        size = size[0], size[1]
+        if size == self.size:
+            return
+
+        # Set new size
+        self.size = size
+        tex_size = size + (1,)
+
+        # Any pipelines are now invalid because they include render targets.
+        self._combine_pass_info = None
+
+        # Recreate internal textures
+        for name, (format, usage) in self._texture_info.items():
+            texture = device.create_texture(
+                size=tex_size, usage=usage, dimension="2d", format=format
+            )
+            setattr(self, name + "_format", format)
+            setattr(self, name + "_tex", texture)
+            setattr(self, name + "_view", texture.create_view())
+
+    # The three methods below represent the API that the render system uses.
+
+    def get_pipeline_targets(self, render_pass):
+        p = getattr(self, f"pass{render_pass}")
+        return p.get_pipeline_targets(self)
+
+    def get_color_attachments(self, render_pass, clear):
+        p = getattr(self, f"pass{render_pass}")
+        return p.get_color_attachments(self, clear)
+
+    def get_shader_code(self, render_pass):
+        p = getattr(self, f"pass{render_pass}")
+        return p.get_shader_code(self)
+
+    def perform_combine_pass(self, device, command_encoder):
+        """Perform a render-pass to combine any multi-pass results, if needed."""
+        # Get bindgroup and pipeline
+        if not self._combine_pass_info:
+            self._combine_pass_info = self._create_combination_pipeline(device)
+        bind_group, render_pipeline = self._combine_pass_info
+        if not render_pipeline:
+            return
+
+        # Render
         render_pass = command_encoder.begin_render_pass(
             color_attachments=[
                 {
-                    "view": dst_color_tex,
+                    "view": self.color_view,
                     "resolve_target": None,
-                    "load_value": (0, 0, 0, 0),  # LoadOp.load or color
+                    "load_value": wgpu.LoadOp.load,
                     "store_op": wgpu.StoreOp.store,
                 }
             ],
@@ -506,28 +484,84 @@ class RenderFlusher:
         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):
+    def _create_combination_pipeline(self, device):
+        """Overload this to setup the specific combiner-pass."""
+        return None, None
+
+
+class OpaqueFragmentBlender(BaseFragmentBlender):
+    """A fragment blender that pretends that all surfaces are opaque,
+    even if they're not.
+    """
+
+    pass1 = FullOpaquePass()
+    pass2 = NullPass()
+
+
+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.
+    """
+
+    pass1 = SimpleSinglePass()
+    pass2 = NullPass()
+
+
+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.
+    """
+
+    pass1 = OpaquePass()
+    pass2 = SimpleSecondPass()
 
-        device = self._device
 
-        shader = self._shader
-        shader.define_binding(
-            0, 0, Binding("u_render", "buffer/uniform", self._uniform_data.dtype)
+class BlendedFragmentBlender(BaseFragmentBlender):
+    """A blend approach that is order independent. This is the same
+    as the weighted blender (McGuire 2013), but without the depth
+    weights.
+    """
+
+    pass1 = OpaquePass()
+    pass2 = McGuirePass("alpha")
+
+    def __init__(self):
+        super().__init__()
+
+        usg = wgpu.TextureUsage
+
+        # The accumulation buffer collects weighted fragments
+        self._texture_info["accum"] = (
+            wgpu.TextureFormat.rgba16float,
+            usg.RENDER_ATTACHMENT | usg.TEXTURE_BINDING,
+        )
+
+        # The reveal buffer collects the weights
+        self._texture_info["reveal"] = (
+            wgpu.TextureFormat.r16float,
+            usg.RENDER_ATTACHMENT | usg.TEXTURE_BINDING,
         )
-        shader_module = device.create_shader_module(code=shader.generate_wgsl())
+
+    def _create_combination_pipeline(self, device):
 
         binding_layouts = [
             {
                 "binding": 0,
                 "visibility": wgpu.ShaderStage.FRAGMENT,
-                "buffer": {"type": wgpu.BufferBindingType.uniform},
+                "sampler": {"type": wgpu.SamplerBindingType.filtering},
             },
             {
                 "binding": 1,
                 "visibility": wgpu.ShaderStage.FRAGMENT,
-                "sampler": {"type": wgpu.SamplerBindingType.filtering},
+                "texture": {
+                    "sample_type": wgpu.TextureSampleType.float,
+                    "view_dimension": wgpu.TextureViewDimension.d2,
+                    "multisampled": False,
+                },
             },
             {
                 "binding": 2,
@@ -539,61 +573,61 @@ class RenderFlusher:
                 },
             },
         ]
+
+        sampler = device.create_sampler(mag_filter="nearest", min_filter="nearest")
+
         bindings = [
+            {"binding": 0, "resource": sampler},
+            {"binding": 1, "resource": self.accum_view},
+            {"binding": 2, "resource": self.reveal_view},
+        ]
+
+        targets = [
             {
-                "binding": 0,
-                "resource": {
-                    "buffer": self._uniform_buffer,
-                    "offset": 0,
-                    "size": self._uniform_data.nbytes,
+                "format": self.color_format,
+                "blend": {
+                    "alpha": (
+                        wgpu.BlendFactor.src_alpha,
+                        wgpu.BlendFactor.one_minus_src_alpha,
+                        wgpu.BlendOperation.add,
+                    ),
+                    "color": (
+                        wgpu.BlendFactor.src_alpha,
+                        wgpu.BlendFactor.one_minus_src_alpha,
+                        wgpu.BlendOperation.add,
+                    ),
                 },
             },
-            {"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
-        )
+        bindings_code = """
+            [[group(0), binding(0)]]
+            var r_sampler: sampler;
+            [[group(0), binding(1)]]
+            var r_accum: texture_2d<f32>;
+            [[group(0), binding(2)]]
+            var r_reveal: texture_2d<f32>;
+        """
 
-        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,
-                            ),
-                        },
-                    }
-                ],
-            },
-        )
+        fragment_code = """
+            let epsilon = 0.00001;
+            let accum = textureSample(r_accum, r_sampler, texcoord).rgba;
+            let reveal = textureSample(r_reveal, r_sampler, texcoord).r;
+            let avg_color = accum.rgb / max(accum.a, epsilon);
+            out.color = vec4<f32>(avg_color, 1.0 - reveal);
+        """
+
+        wgsl = FULL_QUAD_SHADER
+        wgsl = wgsl.replace("BINDINGS_CODE", bindings_code)
+        wgsl = wgsl.replace("FRAGMENT_CODE", fragment_code)
+
+        return _create_pipeline(device, binding_layouts, bindings, targets, wgsl)
+
+
+class WeightedFragmentBlender(BlendedFragmentBlender):
+    """Weighted blended order independent transparency (McGuire 2013),
+    using a general purpose depth weight function.
+    """
 
-        return bind_group, render_pipeline
+    pass1 = OpaquePass()
+    pass2 = McGuirePass("depth")
diff --git a/pygfx/renderers/wgpu/_flusher.py b/pygfx/renderers/wgpu/_flusher.py
new file mode 100644
index 0000000..d3fa9c2
--- /dev/null
+++ b/pygfx/renderers/wgpu/_flusher.py
@@ -0,0 +1,279 @@
+import wgpu
+import numpy as np
+
+
+FULL_QUAD_SHADER = """
+    struct VertexInput {
+        [[builtin(vertex_index)]] index: u32;
+    };
+    struct Varyings {
+        [[location(0)]] texcoord: vec2<f32>;
+        [[builtin(position)]] position: vec4<f32>;
+    };
+    struct FragmentOutput {
+        [[location(0)]] color: vec4<f32>;
+    };
+
+    BINDINGS_CODE
+
+    [[stage(vertex)]]
+    fn vs_main(in: VertexInput) -> Varyings {
+        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[in.index];
+        var varyings: Varyings;
+        varyings.texcoord = vec2<f32>(pos.x, 1.0 - pos.y);
+        varyings.position = vec4<f32>(pos * 2.0 - 1.0, 0.0, 1.0);
+        return varyings;
+    }
+
+    [[stage(fragment)]]
+    fn fs_main(varyings: Varyings) -> FragmentOutput {
+        var out : FragmentOutput;
+        let texcoord = varyings.texcoord;
+
+        FRAGMENT_CODE
+
+        return out;
+    }
+"""
+
+
+def _create_pipeline(device, binding_layouts, bindings, targets, wgsl):
+
+    shader_module = device.create_shader_module(code=wgsl)
+
+    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": targets,
+        },
+    )
+
+    return bind_group, render_pipeline
+
+
+# %%%%%%%%%%
+
+
+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
+
+    def __init__(self, device):
+        self._device = device
+        self._pipelines = {}
+
+        dtype = [("size", "float32", (2,)), ("sigma", "float32"), ("support", "int32")]
+        self._uniform_data = np.zeros((), dtype=dtype)
+        self._uniform_buffer = self._device.create_buffer(
+            size=self._uniform_data.nbytes,
+            usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST,
+        )
+
+    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
+
+        bindings_code = """
+            [[block]]
+            struct Render {
+                size: vec2<f32>;
+                sigma: f32;
+                support: i32;
+            };
+            [[group(0), binding(0)]]
+            var<uniform> u_render: Render;
+            [[group(0), binding(1)]]
+            var r_sampler: sampler;
+            [[group(0), binding(2)]]
+            var r_color: texture_2d<f32>;
+        """
+
+        fragment_code = """
+            // 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.
+            let ref_coord = vec2<f32>(vec2<i32>(texcoord / 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((texcoord - coord) / stepp);  // in src pixels
+                    let t = dist / sigma;
+                    let w = exp(-0.5 * t * t);
+                    val = val + textureSample(r_color, r_sampler, coord) * w;
+                    weight = weight + w;
+                }
+            }
+            out.color = val / weight;
+        """
+
+        wgsl = FULL_QUAD_SHADER
+        wgsl = wgsl.replace("BINDINGS_CODE", bindings_code)
+        wgsl = wgsl.replace("FRAGMENT_CODE", fragment_code)
+
+        # shader_module = device.create_shader_module(code=wgsl)
+
+        sampler = self._device.create_sampler(
+            mag_filter="nearest", min_filter="nearest"
+        )
+
+        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": sampler},
+            {"binding": 2, "resource": src_texture_view},
+        ]
+
+        targets = [
+            {
+                "format": dst_format,
+                "blend": {
+                    "alpha": (
+                        wgpu.BlendFactor.src_alpha,
+                        wgpu.BlendFactor.one_minus_src_alpha,
+                        wgpu.BlendOperation.add,
+                    ),
+                    "color": (
+                        wgpu.BlendFactor.src_alpha,
+                        wgpu.BlendFactor.one_minus_src_alpha,
+                        wgpu.BlendOperation.add,
+                    ),
+                },
+            },
+        ]
+
+        return _create_pipeline(device, binding_layouts, bindings, targets, wgsl)
diff --git a/pygfx/renderers/wgpu/_pipelinebuilder.py b/pygfx/renderers/wgpu/_pipelinebuilder.py
index b0dbe6b..1b4c5d6 100644
--- a/pygfx/renderers/wgpu/_pipelinebuilder.py
+++ b/pygfx/renderers/wgpu/_pipelinebuilder.py
@@ -321,12 +321,8 @@ def compose_render_pipeline(shared, blender, wobject, pipeline_info):
 
     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()
+        depth_write_enabled = render_pass_iter == 1
+        fragment_targets = blender.get_pipeline_targets(render_pass_iter)
 
         if not fragment_targets:
             pipelines[render_pass_iter] = None
diff --git a/pygfx/renderers/wgpu/_renderer.py b/pygfx/renderers/wgpu/_renderer.py
index 2a4a519..d7169fb 100644
--- a/pygfx/renderers/wgpu/_renderer.py
+++ b/pygfx/renderers/wgpu/_renderer.py
@@ -12,6 +12,7 @@ from ...resources import Buffer, Texture, TextureView
 from ...utils import array_from_shadertype
 
 from . import _blender as blender_module
+from ._flusher import RenderFlusher
 from ._pipelinebuilder import ensure_pipeline
 from ._update import update_buffer, update_texture, update_texture_view
 
@@ -165,7 +166,7 @@ class WgpuRenderer(Renderer):
         self.blend_mode = "default"
 
         # Prepare object that performs the final render step into a texture
-        self._flusher = blender_module.RenderFlusher(self._shared.device)
+        self._flusher = RenderFlusher(self._shared.device)
 
         # Initialize a small buffer to read pixel info into
         # Make it 256 bytes just in case (for bytes_per_row)
@@ -225,8 +226,11 @@ class WgpuRenderer(Renderer):
         * "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
+        * "blended": two-pass approach that yields order independent transparency.
+          This is like "weighted" but without the depth weights. Performance is the same.
+        * "weighted: two-pass approach that yields order independent
+          transparency, with depth weighting (McGuire 2013). The depth range affects the
+          (quality of the) visual result.
         * "multilayer2": todo 2-layer MLAB + weighted for the rest?
         """
         return self._blend_mode
@@ -244,6 +248,8 @@ class WgpuRenderer(Renderer):
             "opaque": blender_module.OpaqueFragmentBlender,
             "simple1": blender_module.Simple1FragmentBlender,
             "simple2": blender_module.Simple2FragmentBlender,
+            "blended": blender_module.BlendedFragmentBlender,
+            "weighted": blender_module.WeightedFragmentBlender,
         }
         if value not in m:
             raise ValueError(
@@ -394,7 +400,7 @@ class WgpuRenderer(Renderer):
             if wobject_pipeline:
                 wobject_tuples.append((wobject, wobject_pipeline))
 
-        # Render the scene graph (to the first texture)
+        # Render the scene graph
         command_encoder = device.create_command_encoder()
         self._render_recording(
             command_encoder, wobject_tuples, physical_viewport, clear_color, clear_depth
@@ -470,17 +476,18 @@ class WgpuRenderer(Renderer):
 
             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
 
+            color_attachments = self._blender.get_color_attachments(
+                render_pass_iter, clear_color
+            )
             if not color_attachments:
                 continue
 
@@ -519,6 +526,9 @@ class WgpuRenderer(Renderer):
 
             render_pass.end_pass()
 
+        # Let blender wrap up
+        self._blender.perform_combine_pass(self._shared.device, command_encoder)
+
     def _update_stdinfo_buffer(self, camera, physical_size, logical_size):
         # Update the stdinfo buffer's data
         stdinfo_data = self._shared.stdinfo_buffer.data
diff --git a/pygfx/renderers/wgpu/_shadercomposer.py b/pygfx/renderers/wgpu/_shadercomposer.py
index 91acbd1..c1d2656 100644
--- a/pygfx/renderers/wgpu/_shadercomposer.py
+++ b/pygfx/renderers/wgpu/_shadercomposer.py
@@ -299,25 +299,32 @@ class BaseShader:
         """Generate the final WGSL with the templating resolved by jinja2.
         Also accepts templating variables as kwargs.
         """
-        code1 = self.get_code()
-        t = jinja_env.from_string(code1)
 
-        variables = self.kwargs.copy()
-        variables.update(kwargs)
+        old_kwargs = self.kwargs
+        self.kwargs = old_kwargs.copy()
+        self.kwargs.update(kwargs)
 
-        err_msg = None
         try:
-            code2 = t.render(**variables)
-        except jinja2.UndefinedError as err:
-            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
+            code1 = self.get_code()
+            t = jinja_env.from_string(code1)
+
+            err_msg = None
+            try:
+                code2 = t.render(**self.kwargs)
+            except jinja2.UndefinedError as err:
+                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
+
+        finally:
+            self.kwargs = old_kwargs
 
     def define_binding(self, bindgroup, index, binding):
         """Define a uniform, buffer, sampler, or texture. The produced wgsl
@@ -542,7 +549,8 @@ class WorldObjectShader(BaseShader):
 
         # Get WGSL for blending.
         # Defines FragmentOutput2, add_fragment1, add_fragment2, finalize_fragment1, finalize_fragment2.
-        self["blending_code"] = render_info.blender.get_shader_code()
+        self["blending_code1"] = render_info.blender.get_shader_code(1)
+        self["blending_code2"] = render_info.blender.get_shader_code(2)
 
     def common_functions(self):
 
@@ -576,4 +584,5 @@ class WorldObjectShader(BaseShader):
         }
         """
 
-        return clipping_plane_code + world_pos_code + self["blending_code"]
+        blending_code = self["blending_code" + str(self["render_pass"])]
+        return clipping_plane_code + world_pos_code + blending_code


Test patch:
diff --git a/tests/renderers/test_shader_composer.py b/tests/renderers/test_shader_composer.py
index f309719..28dbe5c 100644
--- a/tests/renderers/test_shader_composer.py
+++ b/tests/renderers/test_shader_composer.py
@@ -30,6 +30,9 @@ def test_templating():
     # Can also specify when generating
     assert shader.generate_wgsl(foo=False).strip() == "x = 43"
 
+    # But these only apply for that call
+    assert shader.generate_wgsl().strip() == "x = 42"
+
     # Inline block notation
     class MyShader(shadercomposer.BaseShader):
         def get_code(self):
@@ -42,6 +45,27 @@ def test_templating():
     assert shader.generate_wgsl(foo=False).strip() == "2"
 
 
+def test_logic_beyond_templating():
+    class MyShader(shadercomposer.BaseShader):
+        def get_code(self):
+            if self["foo"]:
+                return "x = {{bar + 1}}"
+            else:
+                return "x = {{bar}}"
+
+    shader = MyShader(foo=False, bar=24)
+
+    assert shader.generate_wgsl().strip() == "x = 24"
+    shader["foo"] = True
+    assert shader.generate_wgsl().strip() == "x = 25"
+
+    assert shader.generate_wgsl(foo=False).strip() == "x = 24"
+    assert shader.generate_wgsl().strip() == "x = 25"
+
+    assert shader.generate_wgsl(bar=1).strip() == "x = 2"
+    assert shader.generate_wgsl().strip() == "x = 25"
+
+
 def test_uniform_definitions():
     class MyShader(shadercomposer.BaseShader):
         def get_code(self):
@@ -158,5 +182,6 @@ def test_resolve_depth_output():
 
 if __name__ == "__main__":
     test_templating()
+    test_logic_beyond_templating()
     test_uniform_definitions()
     test_resolve_depth_output()