[forma] Add end to end rendering test.

Change-Id: I15fbb049701c7829de24f0af6a3cda84d418a169
Reviewed-on: https://fuchsia-review.googlesource.com/c/forma/+/689485
Reviewed-by: DragoČ™ Tiselice <dtiselice@google.com>
diff --git a/Cargo.toml b/Cargo.toml
index c34c978..6c5fa9f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,6 +6,7 @@
 default-members = [
     "benches",
     "demo",
+    "e2e_tests",
     "forma",
     "gpu/conveyor-sort",
     "gpu/painter",
@@ -16,6 +17,7 @@
 members = [
     "benches",
     "demo",
+    "e2e_tests",
     "forma",
     "gpu/conveyor-sort",
     "gpu/painter",
diff --git a/e2e_tests/Cargo.toml b/e2e_tests/Cargo.toml
new file mode 100644
index 0000000..a4adc3d
--- /dev/null
+++ b/e2e_tests/Cargo.toml
@@ -0,0 +1,19 @@
+# Copyright 2022 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+[package]
+edition = "2021"
+name = "e2e_tests"
+version = "0.1.0"
+
+[dependencies]
+forma = { path = "../forma", features = ["gpu"] }
+image = "0.23"
+wgpu = { git = "https://github.com/gfx-rs/wgpu", rev = "c226a10" }
+pollster = "0.2"
+once_cell = "1.12.0"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0.81"
+anyhow = "1.0.57"
+base64 = "0.13.0"
diff --git a/e2e_tests/expected/tests::blend_modes::Color::cpu.png b/e2e_tests/expected/tests::blend_modes::Color::cpu.png
new file mode 100644
index 0000000..ed1bbd3
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Color::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Color::gpu.png b/e2e_tests/expected/tests::blend_modes::Color::gpu.png
new file mode 100644
index 0000000..187ff4d
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Color::gpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::ColorBurn::cpu.png b/e2e_tests/expected/tests::blend_modes::ColorBurn::cpu.png
new file mode 100644
index 0000000..f2aeffe
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::ColorBurn::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::ColorBurn::gpu.png b/e2e_tests/expected/tests::blend_modes::ColorBurn::gpu.png
new file mode 100644
index 0000000..3391de2
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::ColorBurn::gpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::ColorDodge::cpu.png b/e2e_tests/expected/tests::blend_modes::ColorDodge::cpu.png
new file mode 100644
index 0000000..ae63635
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::ColorDodge::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::ColorDodge::gpu.png b/e2e_tests/expected/tests::blend_modes::ColorDodge::gpu.png
new file mode 100644
index 0000000..ef5e30d
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::ColorDodge::gpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Darken::cpu.png b/e2e_tests/expected/tests::blend_modes::Darken::cpu.png
new file mode 100644
index 0000000..c16f632
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Darken::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Darken::gpu.png b/e2e_tests/expected/tests::blend_modes::Darken::gpu.png
new file mode 100644
index 0000000..a89226e
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Darken::gpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Difference::cpu.png b/e2e_tests/expected/tests::blend_modes::Difference::cpu.png
new file mode 100644
index 0000000..ebc7c0f
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Difference::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Difference::gpu.png b/e2e_tests/expected/tests::blend_modes::Difference::gpu.png
new file mode 100644
index 0000000..babbbad
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Difference::gpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Exclusion::cpu.png b/e2e_tests/expected/tests::blend_modes::Exclusion::cpu.png
new file mode 100644
index 0000000..4400255
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Exclusion::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Exclusion::gpu.png b/e2e_tests/expected/tests::blend_modes::Exclusion::gpu.png
new file mode 100644
index 0000000..9096409
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Exclusion::gpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::HardLight::cpu.png b/e2e_tests/expected/tests::blend_modes::HardLight::cpu.png
new file mode 100644
index 0000000..cdc7610
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::HardLight::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::HardLight::gpu.png b/e2e_tests/expected/tests::blend_modes::HardLight::gpu.png
new file mode 100644
index 0000000..f05aa56
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::HardLight::gpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Hue::cpu.png b/e2e_tests/expected/tests::blend_modes::Hue::cpu.png
new file mode 100644
index 0000000..4189755
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Hue::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Hue::gpu.png b/e2e_tests/expected/tests::blend_modes::Hue::gpu.png
new file mode 100644
index 0000000..ac4c98a
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Hue::gpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Lighten::cpu.png b/e2e_tests/expected/tests::blend_modes::Lighten::cpu.png
new file mode 100644
index 0000000..abdced2
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Lighten::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Lighten::gpu.png b/e2e_tests/expected/tests::blend_modes::Lighten::gpu.png
new file mode 100644
index 0000000..1336159
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Lighten::gpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Luminosity::cpu.png b/e2e_tests/expected/tests::blend_modes::Luminosity::cpu.png
new file mode 100644
index 0000000..7cde9ef
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Luminosity::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Luminosity::gpu.png b/e2e_tests/expected/tests::blend_modes::Luminosity::gpu.png
new file mode 100644
index 0000000..ad72dca
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Luminosity::gpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Multiply::cpu.png b/e2e_tests/expected/tests::blend_modes::Multiply::cpu.png
new file mode 100644
index 0000000..35805f7
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Multiply::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Multiply::gpu.png b/e2e_tests/expected/tests::blend_modes::Multiply::gpu.png
new file mode 100644
index 0000000..b4469eb
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Multiply::gpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Over::cpu.png b/e2e_tests/expected/tests::blend_modes::Over::cpu.png
new file mode 100644
index 0000000..5bdd859
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Over::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Over::gpu.png b/e2e_tests/expected/tests::blend_modes::Over::gpu.png
new file mode 100644
index 0000000..2358f16
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Over::gpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Overlay::cpu.png b/e2e_tests/expected/tests::blend_modes::Overlay::cpu.png
new file mode 100644
index 0000000..4bdcd08
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Overlay::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Overlay::gpu.png b/e2e_tests/expected/tests::blend_modes::Overlay::gpu.png
new file mode 100644
index 0000000..a464a1a
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Overlay::gpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Saturation::cpu.png b/e2e_tests/expected/tests::blend_modes::Saturation::cpu.png
new file mode 100644
index 0000000..f956664
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Saturation::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Saturation::gpu.png b/e2e_tests/expected/tests::blend_modes::Saturation::gpu.png
new file mode 100644
index 0000000..08d4cda
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Saturation::gpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Screen::cpu.png b/e2e_tests/expected/tests::blend_modes::Screen::cpu.png
new file mode 100644
index 0000000..ca8f0fb
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Screen::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::Screen::gpu.png b/e2e_tests/expected/tests::blend_modes::Screen::gpu.png
new file mode 100644
index 0000000..6b569c8
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::Screen::gpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::SoftLight::cpu.png b/e2e_tests/expected/tests::blend_modes::SoftLight::cpu.png
new file mode 100644
index 0000000..089dc5e
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::SoftLight::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::blend_modes::SoftLight::gpu.png b/e2e_tests/expected/tests::blend_modes::SoftLight::gpu.png
new file mode 100644
index 0000000..f4fccaf
--- /dev/null
+++ b/e2e_tests/expected/tests::blend_modes::SoftLight::gpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::clipping::cpu.png b/e2e_tests/expected/tests::clipping::cpu.png
new file mode 100644
index 0000000..76b0d55
--- /dev/null
+++ b/e2e_tests/expected/tests::clipping::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::clipping::gpu.png b/e2e_tests/expected/tests::clipping::gpu.png
new file mode 100644
index 0000000..efa4ae7
--- /dev/null
+++ b/e2e_tests/expected/tests::clipping::gpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::fill_rules::EvenOdd::cpu.png b/e2e_tests/expected/tests::fill_rules::EvenOdd::cpu.png
new file mode 100644
index 0000000..cda929a
--- /dev/null
+++ b/e2e_tests/expected/tests::fill_rules::EvenOdd::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::fill_rules::NonZero::cpu.png b/e2e_tests/expected/tests::fill_rules::NonZero::cpu.png
new file mode 100644
index 0000000..9d69fff
--- /dev/null
+++ b/e2e_tests/expected/tests::fill_rules::NonZero::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::linear_gradient::cpu.png b/e2e_tests/expected/tests::linear_gradient::cpu.png
new file mode 100644
index 0000000..0769a5d
--- /dev/null
+++ b/e2e_tests/expected/tests::linear_gradient::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::linear_gradient::gpu.png b/e2e_tests/expected/tests::linear_gradient::gpu.png
new file mode 100644
index 0000000..93e6d0f
--- /dev/null
+++ b/e2e_tests/expected/tests::linear_gradient::gpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::pixel::cpu.png b/e2e_tests/expected/tests::pixel::cpu.png
new file mode 100644
index 0000000..74da306
--- /dev/null
+++ b/e2e_tests/expected/tests::pixel::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::radial_gradient::cpu.png b/e2e_tests/expected/tests::radial_gradient::cpu.png
new file mode 100644
index 0000000..82a96e0
--- /dev/null
+++ b/e2e_tests/expected/tests::radial_gradient::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::radial_gradient::gpu.png b/e2e_tests/expected/tests::radial_gradient::gpu.png
new file mode 100644
index 0000000..0f31068
--- /dev/null
+++ b/e2e_tests/expected/tests::radial_gradient::gpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::solid_color::blue::cpu.png b/e2e_tests/expected/tests::solid_color::blue::cpu.png
new file mode 100644
index 0000000..7a43adf
--- /dev/null
+++ b/e2e_tests/expected/tests::solid_color::blue::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::solid_color::dark_blue::cpu.png b/e2e_tests/expected/tests::solid_color::dark_blue::cpu.png
new file mode 100644
index 0000000..b1427e3
--- /dev/null
+++ b/e2e_tests/expected/tests::solid_color::dark_blue::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::solid_color::dark_green::cpu.png b/e2e_tests/expected/tests::solid_color::dark_green::cpu.png
new file mode 100644
index 0000000..32643f1
--- /dev/null
+++ b/e2e_tests/expected/tests::solid_color::dark_green::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::solid_color::dark_red::cpu.png b/e2e_tests/expected/tests::solid_color::dark_red::cpu.png
new file mode 100644
index 0000000..b6f7a9d
--- /dev/null
+++ b/e2e_tests/expected/tests::solid_color::dark_red::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::solid_color::green::cpu.png b/e2e_tests/expected/tests::solid_color::green::cpu.png
new file mode 100644
index 0000000..f944ebb
--- /dev/null
+++ b/e2e_tests/expected/tests::solid_color::green::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::solid_color::red::cpu.png b/e2e_tests/expected/tests::solid_color::red::cpu.png
new file mode 100644
index 0000000..55335a0
--- /dev/null
+++ b/e2e_tests/expected/tests::solid_color::red::cpu.png
Binary files differ
diff --git a/e2e_tests/expected/tests::solid_color::transparent_black::cpu.png b/e2e_tests/expected/tests::solid_color::transparent_black::cpu.png
new file mode 100644
index 0000000..246d030
--- /dev/null
+++ b/e2e_tests/expected/tests::solid_color::transparent_black::cpu.png
Binary files differ
diff --git a/e2e_tests/tests/report.html b/e2e_tests/tests/report.html
new file mode 100644
index 0000000..dd27df9
--- /dev/null
+++ b/e2e_tests/tests/report.html
@@ -0,0 +1,295 @@
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8">
+    <title>Forma test report</title>
+    <style>
+        body {
+            font-family: "Segoe UI", Arial, sans-serif;
+        }
+
+        .overview {
+            padding: 0 1rem;
+            margin-bottom: 1rem;
+        }
+
+        .testcase {
+            padding: 0 1rem;
+            margin-bottom: 1rem;
+            border: 1px solid transparent;
+            border-radius: 0.25rem;
+        }
+
+        .status_KO {
+            color: #721c24;
+            background-color: #f8d7da;
+            border-color: #f5c6cb;
+        }
+
+        .status_OK {
+            color: #155724;
+            background-color: #d4edda;
+            border-color: #c3e6cb;
+        }
+
+        h3 {
+            margin: 10px 0;
+        }
+
+        pre {
+            margin-top: 0;
+        }
+
+        .note {
+            height: 2em;
+        }
+
+        .superpose {
+            position: relative;
+            width: 256px;
+            height: 256px;
+            border: 2px solid #000;
+            margin: 4px;
+            background-color: #ffffff;
+        }
+
+        .cpu_gpu_pair {
+            display: flex;
+            flex-direction: row;
+            flex-wrap: wrap;
+        }
+
+        .diff {
+            display: flex;
+            flex-direction: row
+        }
+
+        .viewer {
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            image-rendering: pixelated;
+        }
+
+        .hide_overlay .overlay_canvas {
+            visibility: hidden;
+        }
+
+        .hide_background .background_canvas {
+            visibility: hidden;
+        }
+
+        .superpose canvas {
+            position: absolute;
+            top: 0;
+            left: 0;
+        }
+
+        img {
+            display: none;
+        }
+
+        kbd {
+            padding: 0.2rem 0.4rem;
+            font-size: 87.5%;
+            color: #fff;
+            background-color: #212529;
+            border-radius: 0.2rem;
+        }
+
+        .shortcuts {
+            margin: 1em;
+            text-align: right;
+        }
+
+        .shortcuts div {
+            display: inline-block;
+        }
+
+        .overview {
+            clear: both;
+        }
+    </style>
+
+</head>
+
+<body>
+    <div>
+        <h1 style="float:left">Forma test report</h1>
+        <div class="shortcuts">
+            <div><kbd>b</kbd> toggle background draw checker |</div>
+            <div><kbd>d</kbd> toggle diff overlay.</div>
+        </div>
+    </div>
+    <div class="overview">
+        <div class="template"><a class="status_{status}" href="#{name}">{name}</a></div>
+    </div>
+    <div id="root" class="hide_overlay">
+        <div class="template">
+            <div class="testcase status_{status}" id="{name}">
+                <h3>{name}</h3>
+                <pre>{message}</pre>
+                <div class="cpu_gpu_pair">
+                    <div class="diff">
+                        <div class="viewer cpu_actual">
+                            CPU Actual
+                            <div class="superpose">
+                                <canvas width="256" height="256" class="background_canvas"></canvas>
+                                <canvas width="256" height="256" class="image_canvas"></canvas>
+                                <canvas width="256" height="256" class="overlay_canvas"></canvas>
+                            </div>
+                            <img src="{cpu_actual}"/>
+                            <pre class="note"></pre>
+                        </div>
+                        <div class="viewer" class="cpu_expected">
+                            CPU Expected
+                            <div class="superpose">
+                                <canvas width="256" height="256" class="background_canvas"></canvas>
+                                <canvas width="256" height="256" class="image_canvas"></canvas>
+                                <canvas width="256" height="256" class="overlay_canvas"></canvas>
+                            </div>
+                            <img src="{cpu_expected}"/>
+                            <pre class="note"></pre>
+                        </div>
+                    </div>
+                    <div class="diff">
+                        <div class="viewer" class="gpu_actual">
+                            GPU Actual
+                            <div class="superpose">
+                                <canvas width="256" height="256" class="background_canvas"></canvas>
+                                <canvas width="256" height="256" class="image_canvas"></canvas>
+                                <canvas width="256" height="256" class="overlay_canvas"></canvas>
+                            </div>
+                            <img src="{gpu_actual}"/>
+                            <pre class="note"></pre>
+                        </div>
+                        <div class="viewer" class="gpu_expected">
+                            GPU Expected
+                            <div class="superpose">
+                                <canvas width="256" height="256" class="background_canvas"></canvas>
+                                <canvas width="256" height="256" class="image_canvas"></canvas>
+                                <canvas width="256" height="256" class="overlay_canvas"></canvas>
+                            </div>
+                            <img src="{gpu_expected}"/>
+                            <pre class="note"></pre>
+                        </div>
+                    </div>
+
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <script>
+        // Scale factor expressed as a bit shift.
+        const shift = 2;
+
+        let entries = [/* generated */];
+
+        // Get issues first and the sort by name.
+        entries.sort((a, b) => a.name.localeCompare(b.name));
+        entries.sort((a, b) => a.status.localeCompare(b.status));
+
+        // Instanciate templates.
+        for (const template of Array.from(document.getElementsByClassName('template'))) {
+            const parentNode = template.parentNode;
+            parentNode.removeChild(template);
+            for (const entry of entries) {
+                const elem = document.createElement("div");
+                var html = template.innerHTML
+                for (const property in entry) html = html.replaceAll(`{${property}}`, entry[property]);
+                elem.innerHTML = html;
+                parentNode.appendChild(elem);
+            }
+            console.log(template);
+        }
+
+        // Show color picker on hover.
+        document.addEventListener('mousemove', e => {
+            const get_row = canvas => canvas.closest(".testcase");
+            const get_viewer = canvas => canvas.closest(".viewer");
+            if (e.path[0].tagName !== "CANVAS") return;
+            const row = get_row(e.path[0]);
+            for (const canvas of Array.from(document.getElementsByClassName("image_canvas"))) {
+                const note = get_viewer(canvas).getElementsByClassName("note")[0];
+                if (get_row(canvas).isSameNode(row)) {
+                    try {
+                        const data = canvas.getContext("2d").getImageData(e.offsetX, e.offsetY, 1, 1).data;
+                        const rgba = v => data[v].toString().padStart(3, " ")
+                        note.innerHTML = `(x: ${e.offsetX >> shift}, y: ${e.offsetY >> shift})\nr: ${rgba(0)}, g: ${rgba(1)}, b: ${rgba(2)}, a: ${rgba(3)}`;
+                    } catch (error) {
+                        console.warn(error);
+                    }
+                } else {
+                    note.innerHTML = "";
+                }
+            }
+
+        });
+
+        // Toggle debug view on key press.
+        document.addEventListener('keypress', e => {
+            console.log(e);
+            let elem = document.getElementById("root");
+            if (e.key == "b") elem.classList.toggle("hide_background");
+            if (e.key == "d") elem.classList.toggle("hide_overlay");
+        });
+
+        function drawChecker(canvas) {
+            const rows = 8, cols = 8;
+            const w = canvas.clientWidth, h = canvas.clientHeight;
+            const ctx = canvas.getContext("2d");
+            ctx.clearRect(0, 0, w, h);
+            ctx.fillStyle = "LightGray";
+            for (let r = 0; r < rows; r++) {
+                for (let c = r % 2; c < cols; c += 2) {
+                    ctx.beginPath();
+                    ctx.rect(w * r / rows, h * c / cols, w / rows, h / cols);
+                    ctx.fill();
+                }
+            }
+        }
+
+        // Load the impage into canvas for inspection, and compute diffs.
+        window.onload = function () {
+            for (const elem of Array.from(document.getElementsByClassName("viewer"))) {
+                const img = elem.getElementsByTagName("img")[0];
+                if (img.getAttribute("src") === "null") continue;
+                const context = elem.getElementsByClassName("image_canvas")[0].getContext("2d");
+                context.webkitImageSmoothingEnabled = false;
+                context.mozImageSmoothingEnabled = false;
+                context.msImageSmoothingEnabled = false;
+                context.imageSmoothingEnabled = false;
+                context.drawImage(img, 0, 0, 256, 256);
+            }
+
+            for (const canvas of Array.from(document.getElementsByClassName("background_canvas"))) {
+                drawChecker(canvas);
+            }
+
+            for (const diff of Array.from(document.getElementsByClassName("diff"))) {
+                const canvases = diff.getElementsByClassName("image_canvas");
+                const w = canvases[0].clientWidth, h = canvases[0].clientHeight;
+                let id0 = canvases[0].getContext("2d").getImageData(0, 0, w, h);
+                let id1 = canvases[1].getContext("2d").getImageData(0, 0, w, h);
+                let d0 = id0.data, d1 = id1.data;
+                let colorize = (sum) => {
+                    if (sum > 8) return [255, 60, 0, 128];
+                    else if (sum > 4) return [255, 140, 0, 128];
+                    return [0, 0, 0, 0];
+                }
+                let delta = (i) => Math.abs(d0[i] - d1[i]);
+                for (let i = w * h * 4 - 4; i >= 0; i -= 4) {
+                    const sum = delta(i) + delta(i + 1) + delta(i + 2) + delta(i + 3);
+                    [d0[i], d0[i + 1], d0[i + 2], d0[i + 3]] = colorize(sum);
+                }
+                for (const canvas of Array.from(diff.getElementsByClassName("overlay_canvas"))) {
+                    canvas.getContext("2d").putImageData(id0, 0, 0);
+                }
+            }
+        };
+
+    </script>
+</body>
+
+</html>
\ No newline at end of file
diff --git a/e2e_tests/tests/test_env.rs b/e2e_tests/tests/test_env.rs
new file mode 100644
index 0000000..d67754e
--- /dev/null
+++ b/e2e_tests/tests/test_env.rs
@@ -0,0 +1,373 @@
+/// End to end rendering test for forma GPU and CPU.
+/// A report is written to ${TARGET}/tmp/tests/report.html
+use std::{
+    cell::RefCell,
+    env,
+    fmt::Debug,
+    fs,
+    fs::{create_dir_all, remove_dir_all},
+    num::NonZeroU32,
+    path,
+    path::PathBuf,
+    sync::Mutex,
+};
+
+use anyhow::{anyhow, Context};
+use forma::{
+    buffer::{layout::LinearLayout, BufferBuilder},
+    Color, Composition, CpuRenderer, GpuRenderer, RGBA,
+};
+use image::RgbaImage;
+use once_cell::sync::OnceCell;
+use serde::Serialize;
+
+pub const WIDTH: f32 = 64.0;
+pub const HEIGHT: f32 = 64.0;
+pub const PADDING: f32 = 8.0;
+
+fn cpu_render(composition: &mut Composition, width: usize, height: usize) -> RgbaImage {
+    let mut data = vec![0; width * 4 * height];
+    let mut layout = LinearLayout::new(width, width * 4, height);
+    let mut buffer = BufferBuilder::new(&mut data, &mut layout).build();
+    let mut renderer = CpuRenderer::new();
+    renderer.render(composition, &mut buffer, RGBA, Color { r: 1.0, g: 1.0, b: 1.0, a: 0.0 }, None);
+    let image = RgbaImage::from_raw(width as u32, height as u32, data).unwrap();
+    image
+}
+
+fn gpu_render(composition: &mut Composition, width: usize, height: usize) -> RgbaImage {
+    let instance = wgpu::Instance::new(wgpu::Backends::PRIMARY);
+    let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
+        power_preference: wgpu::PowerPreference::HighPerformance,
+        ..Default::default()
+    }))
+    .expect("failed to find an appropriate adapter");
+
+    let (device, queue) = pollster::block_on(adapter.request_device(
+        &wgpu::DeviceDescriptor {
+            label: None,
+            features: Default::default(),
+            limits: wgpu::Limits {
+                max_texture_dimension_2d: 4096,
+                max_storage_buffer_binding_size: 1 << 30,
+                ..wgpu::Limits::downlevel_defaults()
+            },
+        },
+        None,
+    ))
+    .expect("failed to get device");
+
+    let texture_desc = wgpu::TextureDescriptor {
+        size: wgpu::Extent3d {
+            width: width as u32,
+            height: width as u32,
+            depth_or_array_layers: 1,
+        },
+        mip_level_count: 1,
+        sample_count: 1,
+        dimension: wgpu::TextureDimension::D2,
+        format: wgpu::TextureFormat::Rgba8UnormSrgb,
+        usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::RENDER_ATTACHMENT,
+        label: None,
+    };
+    let texture = device.create_texture(&texture_desc);
+
+    let mut renderer = GpuRenderer::new(&device, texture_desc.format, false);
+    renderer.render_to_texture(
+        composition,
+        &device,
+        &queue,
+        &texture,
+        width as u32,
+        height as u32,
+        Color { r: 1.0, g: 1.0, b: 1.0, a: 0.0 },
+    );
+
+    let output_buffer_size = (4 * width * height) as wgpu::BufferAddress;
+    let output_buffer_desc = wgpu::BufferDescriptor {
+        size: output_buffer_size,
+        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
+        label: None,
+        mapped_at_creation: false,
+    };
+    let output_buffer = device.create_buffer(&output_buffer_desc);
+
+    let mut encoder =
+        device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
+    encoder.copy_texture_to_buffer(
+        wgpu::ImageCopyTexture {
+            aspect: wgpu::TextureAspect::All,
+            texture: &texture,
+            mip_level: 0,
+            origin: wgpu::Origin3d::ZERO,
+        },
+        wgpu::ImageCopyBuffer {
+            buffer: &output_buffer,
+            layout: wgpu::ImageDataLayout {
+                offset: 0,
+                bytes_per_row: NonZeroU32::new(4 * width as u32),
+                rows_per_image: NonZeroU32::new(width as u32),
+            },
+        },
+        texture_desc.size,
+    );
+    queue.submit(Some(encoder.finish()));
+    // We need to scope the mapping variables so that we can
+    // unmap the buffer
+    let image = {
+        let buffer_slice = output_buffer.slice(..);
+        // NOTE: We have to create the mapping THEN device.poll() before await
+        // the future. Otherwise the application will freeze.
+        let mapping = buffer_slice.map_async(wgpu::MapMode::Read);
+        device.poll(wgpu::Maintain::Wait);
+        pollster::block_on(mapping).unwrap();
+
+        let data = buffer_slice.get_mapped_range();
+        RgbaImage::from_raw(width as u32, height as u32, data.to_vec()).unwrap()
+    };
+    output_buffer.unmap();
+    image
+}
+
+fn compare_images(expected: &RgbaImage, actual: &RgbaImage, tolerance: u8) -> anyhow::Result<()> {
+    let offenders = expected
+        .pixels()
+        .zip(actual.pixels())
+        .filter(|(e, a)| e.0.iter().zip(a.0.iter()).any(|(e, a)| e.abs_diff(*a) > tolerance))
+        .count();
+    if offenders > 0 {
+        Err(anyhow!(
+            "{} pixels differs have a channel that differ by more than {} units",
+            offenders,
+            tolerance
+        ))
+    } else {
+        Ok(())
+    }
+}
+
+#[derive(Debug)]
+enum RendererType {
+    CPU,
+    GPU,
+}
+
+#[derive(Serialize)]
+struct TestReportEntry {
+    // Unique name for this test.
+    name: String,
+    // Actual and expected strings contains the image encoded as
+    // valid `src` attribute for an HTML `img` element.
+    cpu_actual: String,
+    // `None` when no reference image file exist.
+    // CPU reference is required for test success.
+    // It is typically missing when a new test is created.
+    cpu_expected: Option<String>,
+    gpu_actual: String,
+    // `None` when no reference exist.
+    gpu_expected: Option<String>,
+    // "OK" or "KO".
+    status: String,
+    // Test status message. Empty for successful tests.
+    message: String,
+}
+
+// Report collecting all information about all tests that is used for
+// HTML report generation.
+struct TestReport {
+    output_dir: PathBuf,
+    entries: Mutex<Vec<TestReportEntry>>,
+}
+
+// Object implementing soft failure so that we can collect
+pub struct TestEnv {
+    test_name: String,
+    failures: RefCell<Vec<String>>,
+}
+
+impl TestEnv {
+    pub fn new(test_name: &str) -> TestEnv {
+        TestEnv { test_name: test_name.to_string(), failures: RefCell::new(vec![]) }
+    }
+    pub fn test_render<F>(self, compose: F)
+    where
+        F: Fn(&mut Composition) -> (),
+    {
+        if let Err(err) = test_render(compose, &self.test_name) {
+            self.failures.borrow_mut().push(format!("{:?}", err));
+        }
+    }
+
+    pub fn test_render_param<F, T: Debug>(&self, compose: F, t: T)
+    where
+        F: Fn(&mut Composition) -> (),
+    {
+        let t = format!("{:?}", t).replace("\"", "");
+        if let Err(err) = test_render(compose, &format!("{}::{}", self.test_name, t)) {
+            self.failures.borrow_mut().push(format!("{:?}", err));
+        }
+    }
+}
+
+// Implements soft failure so that all test can run.
+impl Drop for TestEnv {
+    fn drop(&mut self) {
+        if self.failures.borrow().is_empty() {
+            return;
+        }
+
+        println!("Test {}:", self.test_name);
+        self.failures
+            .borrow()
+            .iter()
+            .enumerate()
+            .for_each(|(i, e)| println!(" - Error [{}]: {}", i, e));
+        println!("Report generated at target/tmp/tests/report.html");
+        panic!();
+    }
+}
+
+fn test_render<F>(compose: F, test_name: &str) -> anyhow::Result<()>
+where
+    F: Fn(&mut Composition) -> (),
+{
+    let cpu_actual = {
+        let mut composition = Composition::new();
+        compose(&mut composition);
+        cpu_render(&mut composition, WIDTH as usize, HEIGHT as usize)
+    };
+
+    let gpu_actual = {
+        let mut composition = Composition::new();
+        compose(&mut composition);
+        gpu_render(&mut composition, WIDTH as usize, HEIGHT as usize)
+    };
+
+    let result = (|| {
+        let tolerance = 8;
+        let expected_cpu = expected_image(test_name, RendererType::CPU)
+            .context("CPU reference image is missing.")?;
+        compare_images(&expected_cpu, &cpu_actual, tolerance)
+            .context("CPU result differs from the reference image.")?;
+        let expected_gpu = expected_image(test_name, RendererType::GPU).unwrap_or(expected_cpu);
+        compare_images(&expected_gpu, &gpu_actual, tolerance)
+            .context("GPU result differs from the reference image.")
+    })();
+    add_result_to_report(test_name, &cpu_actual, &gpu_actual, &result);
+    result
+}
+
+/// Path to the reference image to which the rendering is compared to.
+fn expected_image_path(test_name: &str, renderer: RendererType) -> PathBuf {
+    let renderer = format!("{:?}", renderer).to_lowercase();
+    env::current_dir().unwrap().join("expected").join(format!("{}::{}.png", test_name, renderer))
+}
+
+/// Returns the reference image buffer if the such image exist.
+fn expected_image(test_name: &str, renderer: RendererType) -> anyhow::Result<RgbaImage> {
+    let path = expected_image_path(test_name, renderer);
+    if !path.exists() {
+        return Err(anyhow!("Unable to open file {:?}", path));
+    }
+    // Panic if the file exists but is not valid.
+    Ok(image::io::Reader::open(path)
+        .context("Unable to open file")?
+        .decode()
+        .context("Unable to open file")?
+        .into_rgba8())
+}
+
+fn add_result_to_report(
+    test_name: &str,
+    cpu_actual: &RgbaImage,
+    gpu_actual: &RgbaImage,
+    status: &anyhow::Result<()>,
+) {
+    static REPORT: OnceCell<Mutex<TestReport>> = OnceCell::new();
+    let lock = REPORT
+        .get_or_init(|| {
+            let output_dir =
+                path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(env!("CARGO_CRATE_NAME"));
+            let _ = remove_dir_all(&output_dir);
+            create_dir_all(&output_dir).unwrap();
+            Mutex::new(TestReport::new(output_dir))
+        })
+        .lock();
+    let report = lock.unwrap();
+
+    let cpu_expected = expected_image_path(test_name, RendererType::CPU);
+    let gpu_expected = expected_image_path(test_name, RendererType::GPU);
+    report.add_result(
+        test_name,
+        cpu_expected.exists().then(|| cpu_expected),
+        cpu_actual,
+        gpu_expected.exists().then(|| gpu_expected),
+        gpu_actual,
+        status,
+    );
+}
+
+impl TestReport {
+    fn new(output_dir: path::PathBuf) -> TestReport {
+        TestReport { output_dir: output_dir, entries: Mutex::new(vec![]) }
+    }
+
+    fn add_result(
+        &self,
+        test_name: &str,
+        cpu_expected: Option<PathBuf>,
+        cpu_actual: &RgbaImage,
+        gpu_expected: Option<PathBuf>,
+        gpu_actual: &RgbaImage,
+        status: &anyhow::Result<()>,
+    ) {
+        let path =
+            |dir, suffix| self.output_dir.join(dir).join(format!("{}::{}.png", test_name, suffix));
+        let to_base64_img_src = |path: &path::PathBuf| {
+            format!("data:image/png;base64, {}", base64::encode(fs::read(path).unwrap()))
+        };
+        let save_actual = |image: &RgbaImage, suffix| {
+            let path = path("actual", suffix);
+            fs::create_dir_all(&path.parent().unwrap()).unwrap();
+            image.save(&path).unwrap();
+            to_base64_img_src(&path)
+        };
+        let entry = TestReportEntry {
+            name: test_name.to_string(),
+            cpu_actual: save_actual(cpu_actual, "cpu"),
+            cpu_expected: cpu_expected.as_ref().map(to_base64_img_src),
+            gpu_actual: save_actual(gpu_actual, "gpu"),
+            gpu_expected: gpu_expected.or(cpu_expected).as_ref().map(to_base64_img_src),
+            status: match status {
+                Ok(_) => "OK".to_owned(),
+                Err(_) => "KO".to_owned(),
+            },
+            message: match status {
+                Ok(_) => "".to_owned(),
+                Err(e) => format!("{:?}", e),
+            },
+        };
+        let mut entries = self.entries.lock().unwrap();
+        entries.push(entry);
+
+        // Update the report every time.
+        // The is no hook in the test framework to generate this report at the end.
+        let report = include_str!("report.html").replace(
+            "[/* generated */]",
+            &serde_json::to_string_pretty(entries.as_slice()).unwrap(),
+        );
+        fs::write(&self.output_dir.join("report.html"), report).unwrap();
+    }
+}
+
+macro_rules! test_env {
+    () => {{
+        fn f() {}
+        fn type_name_of<T>(_: T) -> &'static str {
+            std::any::type_name::<T>()
+        }
+
+        TestEnv::new(type_name_of(f).strip_prefix("tests::").unwrap().strip_suffix("::f").unwrap())
+    }};
+}
+pub(crate) use test_env;
diff --git a/e2e_tests/tests/tests.rs b/e2e_tests/tests/tests.rs
new file mode 100644
index 0000000..69da6c9
--- /dev/null
+++ b/e2e_tests/tests/tests.rs
@@ -0,0 +1,330 @@
+mod test_env;
+
+#[cfg(test)]
+mod tests {
+    use super::test_env::*;
+    use forma::{
+        BlendMode, Color, Fill, FillRule, Func, Gradient, GradientBuilder, Order, Path,
+        PathBuilder, Point, Props, Style,
+    };
+
+    fn triangle() -> Path {
+        PathBuilder::new()
+            .move_to(Point { x: PADDING, y: PADDING })
+            .line_to(Point { x: WIDTH - PADDING, y: PADDING })
+            .line_to(Point { x: WIDTH - PADDING, y: HEIGHT - PADDING })
+            .build()
+    }
+
+    fn square() -> Path {
+        PathBuilder::new()
+            .move_to(Point { x: PADDING, y: PADDING })
+            .line_to(Point { x: WIDTH - PADDING, y: PADDING })
+            .line_to(Point { x: WIDTH - PADDING, y: HEIGHT - PADDING })
+            .line_to(Point { x: PADDING, y: HEIGHT - PADDING })
+            .build()
+    }
+
+    fn inner_square() -> Path {
+        PathBuilder::new()
+            .move_to(Point { x: PADDING * 2.0, y: PADDING * 2.0 })
+            .line_to(Point { x: WIDTH - PADDING * 2.0, y: PADDING * 2.0 })
+            .line_to(Point { x: WIDTH - PADDING * 2.0, y: HEIGHT - PADDING * 2.0 })
+            .line_to(Point { x: PADDING * 2.0, y: HEIGHT - PADDING * 2.0 })
+            .build()
+    }
+
+    fn circle() -> Path {
+        let weight = 2.0f32.sqrt() / 2.0;
+        let x = WIDTH * 0.5;
+        let y = HEIGHT * 0.5;
+        let radius = WIDTH * 0.5 - PADDING;
+        PathBuilder::new()
+            .move_to(Point::new(x + radius, y))
+            .rat_quad_to(Point::new(x + radius, y - radius), Point::new(x, y - radius), weight)
+            .rat_quad_to(Point::new(x - radius, y - radius), Point::new(x - radius, y), weight)
+            .rat_quad_to(Point::new(x - radius, y + radius), Point::new(x, y + radius), weight)
+            .rat_quad_to(Point::new(x + radius, y + radius), Point::new(x + radius, y), weight)
+            .build()
+    }
+
+    fn rainbow_colors(gradient_builder: &mut GradientBuilder) {
+        gradient_builder
+            .color(Color { r: 1.00, g: 0.00, b: 0.00, a: 1.0 })
+            .color(Color { r: 1.00, g: 0.32, b: 0.00, a: 1.0 })
+            .color(Color { r: 0.63, g: 0.73, b: 0.02, a: 1.0 })
+            .color(Color { r: 0.08, g: 0.72, b: 0.07, a: 1.0 })
+            .color(Color { r: 0.05, g: 0.70, b: 0.69, a: 1.0 })
+            .color(Color { r: 0.03, g: 0.58, b: 0.76, a: 1.0 })
+            .color(Color { r: 0.01, g: 0.21, b: 0.85, a: 1.0 })
+            .color(Color { r: 0.11, g: 0.01, b: 0.89, a: 1.0 })
+            .color(Color { r: 0.49, g: 0.00, b: 0.94, a: 1.0 })
+            .color(Color { r: 0.96, g: 0.00, b: 0.69, a: 1.0 })
+            .color(Color { r: 1.00, g: 0.00, b: 0.00, a: 1.0 });
+    }
+
+    fn vertical_rainbow() -> Gradient {
+        let mut gradient_builder = GradientBuilder::new(
+            Point { x: PADDING, y: 0.0 },
+            Point { x: WIDTH - PADDING, y: 0.0 },
+        );
+        rainbow_colors(&mut gradient_builder);
+        gradient_builder.build().unwrap()
+    }
+
+    fn horizontal_rainbow() -> Gradient {
+        let mut gradient_builder = GradientBuilder::new(
+            Point { x: 0.0, y: PADDING },
+            Point { x: 0.0, y: WIDTH - PADDING },
+        );
+        rainbow_colors(&mut gradient_builder);
+        gradient_builder.build().unwrap()
+    }
+
+    fn solid_color_props(color: Color) -> Props {
+        Props {
+            func: Func::Draw(Style { fill: Fill::Solid(color), ..Default::default() }),
+            ..Default::default()
+        }
+    }
+
+    #[test]
+    fn linear_gradient() {
+        let test_env = test_env!();
+        test_env.test_render(|composition| {
+            let mut gradient_builder = GradientBuilder::new(
+                Point { x: PADDING, y: 0.0 },
+                Point { x: WIDTH - PADDING, y: 0.0 },
+            );
+            gradient_builder
+                .color(Color { r: 0.0, g: 0.0, b: 1.0, a: 1.0 })
+                .color(Color { r: 1.0, g: 1.0, b: 1.0, a: 1.0 })
+                .color(Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 });
+            let props = Props {
+                func: Func::Draw(Style {
+                    fill: Fill::Gradient(gradient_builder.build().unwrap()),
+                    ..Default::default()
+                }),
+                ..Default::default()
+            };
+            composition
+                .get_mut_or_insert_default(Order::new(1).unwrap())
+                .insert(&triangle())
+                .set_props(props);
+        });
+    }
+
+    #[test]
+    fn radial_gradient() {
+        let test_env = test_env!();
+        test_env.test_render(|composition| {
+            let mut gradient_builder = GradientBuilder::new(
+                Point { x: WIDTH * 0.5, y: HEIGHT * 0.5 },
+                Point { x: WIDTH - PADDING * 2.0, y: HEIGHT * 0.5 },
+            );
+            gradient_builder
+                .r#type(forma::GradientType::Radial)
+                .color(Color { r: 0.0, g: 0.0, b: 1.0, a: 1.0 })
+                .color(Color { r: 1.0, g: 1.0, b: 1.0, a: 1.0 })
+                .color(Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 });
+            let props = Props {
+                func: Func::Draw(Style {
+                    fill: Fill::Gradient(gradient_builder.build().unwrap()),
+                    ..Default::default()
+                }),
+                ..Default::default()
+            };
+            composition
+                .get_mut_or_insert_default(Order::new(1).unwrap())
+                .insert(&circle())
+                .set_props(props);
+        });
+    }
+
+    #[test]
+    fn solid_color() {
+        let test_env = test_env!();
+        let colors = vec![
+            (Color { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, "blue"),
+            (Color { r: 0.0, g: 0.0, b: 0.5, a: 1.0 }, "dark_blue"),
+            (Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, "red"),
+            (Color { r: 0.5, g: 0.0, b: 0.0, a: 1.0 }, "dark_red"),
+            (Color { r: 0.0, g: 1.0, b: 0.0, a: 1.0 }, "green"),
+            (Color { r: 0.0, g: 0.5, b: 0.0, a: 1.0 }, "dark_green"),
+            (Color { r: 0.0, g: 0.0, b: 0.0, a: 0.5 }, "transparent_black"),
+        ];
+        for (color, name) in colors {
+            test_env.test_render_param(
+                |composition| {
+                    composition
+                        .get_mut_or_insert_default(Order::new(1).unwrap())
+                        .insert(&square())
+                        .set_props(solid_color_props(color));
+                },
+                name,
+            );
+        }
+    }
+
+    #[test]
+    fn pixel() {
+        // This test is useful when the reasterizer is brocken as it emmits 2 pixel segments.
+        test_env!().test_render(|composition| {
+            composition
+                .get_mut_or_insert_default(Order::new(1).unwrap())
+                .insert(
+                    &PathBuilder::new()
+                        .move_to(Point { x: PADDING, y: PADDING })
+                        .line_to(Point { x: PADDING + 1.0, y: PADDING })
+                        .line_to(Point { x: PADDING + 1.0, y: PADDING + 1.0 })
+                        .line_to(Point { x: PADDING, y: PADDING + 1.0 })
+                        .build(),
+                )
+                .set_props(solid_color_props(Color { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }));
+        });
+    }
+
+    #[test]
+    fn blend_modes() {
+        let test_env = test_env!();
+        let blend_modes = [
+            BlendMode::Over,
+            BlendMode::Multiply,
+            BlendMode::Screen,
+            BlendMode::Overlay,
+            BlendMode::Darken,
+            BlendMode::Lighten,
+            BlendMode::ColorDodge,
+            BlendMode::ColorBurn,
+            BlendMode::HardLight,
+            BlendMode::SoftLight,
+            BlendMode::Difference,
+            BlendMode::Exclusion,
+            BlendMode::Hue,
+            BlendMode::Saturation,
+            BlendMode::Color,
+            BlendMode::Luminosity,
+        ];
+        for blend_mode in blend_modes {
+            test_env.test_render_param(
+                |composition| {
+                    composition
+                        .get_mut_or_insert_default(Order::new(0).unwrap())
+                        .insert(&square())
+                        .set_props(Props {
+                            func: Func::Draw(Style {
+                                fill: Fill::Gradient(horizontal_rainbow()),
+                                ..Default::default()
+                            }),
+                            ..Default::default()
+                        });
+
+                    composition
+                        .get_mut_or_insert_default(Order::new(1).unwrap())
+                        .insert(&triangle())
+                        .set_props(Props {
+                            func: Func::Draw(Style {
+                                fill: Fill::Gradient(vertical_rainbow()),
+                                blend_mode,
+                                ..Default::default()
+                            }),
+                            ..Default::default()
+                        });
+                },
+                blend_mode,
+            );
+        }
+    }
+
+    #[test]
+    fn fill_rules() {
+        let test_env = test_env!();
+        let fill_rules = [FillRule::EvenOdd, FillRule::NonZero];
+        for fill_rule in fill_rules {
+            test_env.test_render_param(
+                |composition| {
+                    let path = PathBuilder::new()
+                        .move_to(Point { x: PADDING, y: PADDING })
+                        .line_to(Point { x: WIDTH / 2.0 + PADDING, y: HEIGHT / 2.0 + PADDING })
+                        .line_to(Point { x: WIDTH / 2.0 - PADDING, y: HEIGHT / 2.0 + PADDING })
+                        .line_to(Point { x: WIDTH - PADDING, y: PADDING })
+                        .line_to(Point { x: WIDTH - PADDING, y: HEIGHT - PADDING })
+                        .line_to(Point { x: PADDING, y: HEIGHT - PADDING })
+                        .build();
+                    composition
+                        .get_mut_or_insert_default(Order::new(0).unwrap())
+                        .insert(&path)
+                        .set_props(Props {
+                            fill_rule,
+                            func: Func::Draw(Style {
+                                fill: Fill::Solid(Color { r: 0.0, g: 0.0, b: 0.0, a: 0.8 }),
+                                ..Default::default()
+                            }),
+                        });
+                },
+                fill_rule,
+            );
+        }
+    }
+
+    #[test]
+    fn clipping() {
+        let test_env = test_env!();
+        test_env.test_render(|composition| {
+            // First layer is not clipped.
+            composition
+                .get_mut_or_insert_default(Order::new(0).unwrap())
+                .insert(&square())
+                .set_props(solid_color_props(Color { r: 0.0, g: 0.0, b: 0.0, a: 0.2 }));
+
+            // Triangular clip shape applies to the next 3 layers ids.
+            let props = Props { func: Func::Clip(3), ..Default::default() };
+            composition
+                .get_mut_or_insert_default(Order::new(1).unwrap())
+                .insert(&triangle())
+                .set_props(props);
+
+            // The blue square is clipped.
+            composition
+                .get_mut_or_insert_default(Order::new(2).unwrap())
+                .insert(&square())
+                .set_props(Props {
+                    func: Func::Draw(Style {
+                        fill: Fill::Solid(Color { r: 0.5, g: 0.5, b: 1.0, a: 0.6 }),
+                        is_clipped: true,
+                        ..Default::default()
+                    }),
+                    ..Default::default()
+                });
+
+            // Order No. 3 is intentionnaly left empty to test the clip implementation.
+
+            // The pink circle is clipped.
+            composition
+                .get_mut_or_insert_default(Order::new(4).unwrap())
+                .insert(&circle())
+                .set_props(Props {
+                    func: Func::Draw(Style {
+                        fill: Fill::Solid(Color { r: 1.0, g: 0.5, b: 0.5, a: 0.6 }),
+                        is_clipped: true,
+                        ..Default::default()
+                    }),
+                    ..Default::default()
+                });
+
+            // This is not drawn given that `is_clipped: true` and no clipping
+            // is active at order 5.
+            composition
+                .get_mut_or_insert_default(Order::new(5).unwrap())
+                .insert(&inner_square())
+                .set_props(Props {
+                    func: Func::Draw(Style {
+                        fill: Fill::Solid(Color { r: 0.5, g: 0.5, b: 1.0, a: 0.6 }),
+                        is_clipped: true,
+                        ..Default::default()
+                    }),
+                    ..Default::default()
+                });
+        });
+    }
+}
diff --git a/gpu/renderer/src/lib.rs b/gpu/renderer/src/lib.rs
index eab569b..d3be4e6 100644
--- a/gpu/renderer/src/lib.rs
+++ b/gpu/renderer/src/lib.rs
@@ -108,7 +108,7 @@
         &mut self,
         device: &wgpu::Device,
         queue: &wgpu::Queue,
-        surface: &wgpu::Surface,
+        texture: &wgpu::Texture,
         width: u32,
         height: u32,
         segments: &[u64],
@@ -116,8 +116,7 @@
         styles: &[u32],
         background_color: Color,
     ) -> Result<Option<Timings>, Error> {
-        let frame = surface.get_current_texture()?;
-        let view = frame.texture.create_view(&wgpu::TextureViewDescriptor::default());
+        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
 
         let timestamp_context = self.has_timestamp_query.then(|| {
             let timestamp = device.create_query_set(&wgpu::QuerySetDescriptor {
@@ -176,8 +175,16 @@
             let len_in_blocks =
                 u32::try_from(div_round_up(old_len, BLOCK_SIZE.block_len as usize)).unwrap();
 
-            segments
-                .resize((len_in_blocks * BLOCK_SIZE.block_len) as usize, bytemuck::cast(u64::MAX));
+            // Adds padding to the data to please the sort. `BLOCK_SIZE.block_len` should
+            // be enough, but twice this amount is required for an unknown reason.
+            // TODO remove this when the sort is replaced by a better version.
+            segments.resize(
+                std::cmp::max(
+                    (len_in_blocks * BLOCK_SIZE.block_len) as usize,
+                    (BLOCK_SIZE.block_len * 2) as usize,
+                ),
+                bytemuck::cast(u64::MAX),
+            );
 
             let slice_size = segments.len() * std::mem::size_of::<u64>();
             let size = slice_size as wgpu::BufferAddress;
@@ -288,7 +295,6 @@
         }
 
         queue.submit(Some(encoder.finish()));
-        frame.present();
 
         let timings = timestamp_context.as_ref().map(|(_, timestamp_period, data_buffer)| {
             use bytemuck::{Pod, Zeroable};