Repository: LiveSplit/livesplit-core
Pull request #408 (2021-02-07 00:13:43)

Turn the renderer into a purely path based renderer
Our renderer always used paths internally but those quickly got turned into meshes. It turns out that a lot of backends can actually directly work with those paths, so splitting out the path rendering makes a lot of sense. This also enables the use of `tiny-skia` for the software renderer. However here is where it gets really surprising: It turns out that `tiny-skia` is so fast, that it outperforms the GPU. And this is without any optimizations. This isn't entirely surprising as Chrome works in a very similar way where the GPU is mostly just used to compose the different Skia layers together. So for now this also removes the mesh based renderer entirely. We are going to investigate rendering into different layers, so that's where a GPU backend may come back into play, but not in the form of a mesh based renderer. 

Additionally this adds support for color fonts using the `CPAL` and `COLR` tables.

This also temporarily disables the WASI tests as we are apparently hitting wasmtime limits atm.


Patch:
diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml
index 7e50c6e..12242dd 100644
--- a/.github/workflows/rust.yml
+++ b/.github/workflows/rust.yml
@@ -83,9 +83,6 @@ jobs:
           # - Linux sparc64
 
           # macOS
-          # 32-bit macOS is deprecated and doesn't build:
-          # https://github.com/CryZe/livesplit-core/runs/220089673
-          # - macOS i686
           - macOS x86_64
 
           # iOS
@@ -277,6 +274,9 @@ jobs:
             target: wasm32-wasi
             os: ubuntu-latest
             cross: skip
+            # FIXME: We are currently hitting limits in wasmtime. We need to fix
+            # wasmtime before we can reenable these tests.
+            tests: skip
             dylib: skip
             install_target: true
 
@@ -466,12 +466,6 @@ jobs:
           #   tests: skip
 
           # macOS
-          # - label: macOS i686
-          #   target: i686-apple-darwin
-          #   toolchain: stable-i686-apple-darwin
-          #   os: macOS-latest
-          #   cross: skip
-
           - label: macOS x86_64
             target: x86_64-apple-darwin
             os: macOS-latest
diff --git a/Cargo.toml b/Cargo.toml
index 5f99f39..cda9f64 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -56,17 +56,15 @@ serde_json = { version = "1.0.8", optional = true }
 utf-8 = { version = "0.7.4", optional = true }
 
 # Rendering
-euclid = { version = "0.20.0", default-features = false, optional = true }
-lyon = { version = "0.16.0", default-features = false, optional = true }
+euclid = { version = "0.22.1", default-features = false, optional = true }
 rustybuzz = { version = "0.3.0", optional = true }
-ttf-parser = { version = "0.9.0", optional = true }
+ttf-parser = { version = "0.11.0", optional = true }
 
 # Font Loading
 font-kit = { version = "0.10.0", optional = true }
 
 # Software Rendering
-euc = { version = "0.5.2", default-features = false, features = ["libm"], optional = true }
-vek = { version = "0.12.1", default-features = false, features = ["libm"], optional = true }
+tiny-skia = { version = "0.4.2", optional = true }
 
 # Networking
 splits-io-api = { version = "0.2.0", optional = true }
@@ -85,12 +83,12 @@ criterion = "0.3.0"
 [features]
 default = ["image-shrinking", "std"]
 doesnt-have-atomics = []
-std = ["byteorder", "chrono/std", "chrono/clock", "euc/std", "image", "indexmap", "livesplit-hotkey/std", "palette/std", "parking_lot", "quick-xml", "serde_json", "serde/std", "snafu/std", "utf-8", "vek/std"]
+std = ["byteorder", "chrono/std", "chrono/clock", "image", "indexmap", "livesplit-hotkey/std", "palette/std", "parking_lot", "quick-xml", "serde_json", "serde/std", "snafu/std", "utf-8"]
 more-image-formats = ["image/webp", "image/pnm", "image/ico", "image/jpeg", "image/tiff", "image/tga", "image/bmp", "image/hdr"]
 image-shrinking = ["std", "bytemuck", "more-image-formats"]
-rendering = ["std", "more-image-formats", "euclid", "lyon", "ttf-parser", "rustybuzz", "bytemuck/derive"]
+rendering = ["std", "more-image-formats", "euclid", "ttf-parser", "rustybuzz", "bytemuck/derive"]
 font-loading = ["std", "rendering", "font-kit"]
-software-rendering = ["rendering", "euc", "vek", "derive_more/mul"]
+software-rendering = ["rendering", "tiny-skia"]
 wasm-web = ["std", "web-sys", "chrono/wasmbind", "livesplit-hotkey/wasm-web"]
 networking = ["std", "splits-io-api"]
 
diff --git a/benches/dummy_rendering.rs b/benches/dummy_rendering.rs
index 987840c..9a9854d 100644
--- a/benches/dummy_rendering.rs
+++ b/benches/dummy_rendering.rs
@@ -1,37 +1,44 @@
 cfg_if::cfg_if! {
     if #[cfg(feature = "rendering")] {
-        use {
-            criterion::{criterion_group, criterion_main, Criterion},
-            livesplit_core::{
-                layout::{self, Layout},
-                run::parser::livesplit,
-                rendering::{Backend, Renderer, Rgba, Transform, Mesh},
-                Run, Segment, TimeSpan, Timer, TimingMethod,
-            },
-            std::{fs::File, io::BufReader},
+        use criterion::{criterion_group, criterion_main, Criterion};
+        use livesplit_core::{
+            layout::{self, Layout},
+            rendering::{Backend, FillShader, PathBuilder, Renderer, Rgba, Transform},
+            run::parser::livesplit,
+            Run, Segment, TimeSpan, Timer, TimingMethod,
         };
+        use std::{fs::File, io::BufReader};
 
         criterion_main!(benches);
         criterion_group!(benches, default, subsplits_layout);
 
         struct Dummy;
 
+        impl PathBuilder<Dummy> for Dummy {
+            type Path = ();
+
+            fn move_to(&mut self, _: f32, _: f32) {}
+            fn line_to(&mut self, _: f32, _: f32) {}
+            fn quad_to(&mut self, _: f32, _: f32, _: f32, _: f32) {}
+            fn curve_to(&mut self, _: f32, _: f32, _: f32, _: f32, _: f32, _: f32) {}
+            fn close(&mut self) {}
+            fn finish(self, _: &mut Dummy) -> Self::Path {}
+        }
+
         impl Backend for Dummy {
-            type Mesh = ();
-            type Texture = ();
-
-            fn create_mesh(&mut self, _: &Mesh) -> Self::Mesh {}
-            fn render_mesh(
-                &mut self,
-                _: &Self::Mesh,
-                _: Transform,
-                _: [Rgba; 4],
-                _: Option<&Self::Texture>,
-            ) {}
-            fn free_mesh(&mut self, _: Self::Mesh) {}
-            fn create_texture(&mut self, _: u32, _: u32, _: &[u8]) -> Self::Texture {}
-            fn free_texture(&mut self, _: Self::Texture) {}
-            fn resize(&mut self, _: f32, _: f32) {}
+            type PathBuilder = Dummy;
+            type Path = ();
+            type Image = ();
+
+            fn path_builder(&mut self) -> Self::PathBuilder {
+                Dummy
+            }
+            fn render_fill_path(&mut self, _: &Self::Path, _: FillShader, _: Transform) {}
+            fn render_stroke_path(&mut self, _: &Self::Path, _: f32, _: Rgba, _: Transform) {}
+            fn render_image(&mut self, _: &Self::Image, _: &Self::Path, _: Transform) {}
+            fn free_path(&mut self, _: Self::Path) {}
+            fn create_image(&mut self, _: u32, _: u32, _: &[u8]) -> Self::Image {}
+            fn free_image(&mut self, _: Self::Image) {}
         }
 
         fn default(c: &mut Criterion) {
diff --git a/benches/software_rendering.rs b/benches/software_rendering.rs
index 176a80a..711e47d 100644
--- a/benches/software_rendering.rs
+++ b/benches/software_rendering.rs
@@ -1,10 +1,10 @@
 cfg_if::cfg_if! {
     if #[cfg(feature = "software-rendering")] {
         use {
-            criterion::{criterion_group, criterion_main, Benchmark, Criterion},
+            criterion::{criterion_group, criterion_main, Criterion},
             livesplit_core::{
                 layout::{self, Layout},
-                rendering::software,
+                rendering::software::SoftwareRenderer,
                 run::parser::livesplit,
                 Run, Segment, TimeSpan, Timer, TimingMethod,
             },
@@ -25,10 +25,12 @@ cfg_if::cfg_if! {
             start_run(&mut timer);
             make_progress_run_with_splits_opt(&mut timer, &[Some(5.0), None, Some(10.0)]);
 
-            let state = layout.state(&timer.snapshot());
+            let snapshot = timer.snapshot();
+            let state = layout.state(&snapshot);
+            let mut renderer = SoftwareRenderer::new();
 
             c.bench_function("Software Rendering (Default)", move |b| {
-                b.iter(|| software::render(&state, [300, 500]))
+                b.iter(|| renderer.render(&state, [300, 500]))
             });
         }
 
@@ -42,27 +44,11 @@ cfg_if::cfg_if! {
 
             let snapshot = timer.snapshot();
             let state = layout.state(&snapshot);
+            let mut renderer = SoftwareRenderer::new();
 
             c.bench_function("Software Rendering (Subsplits Layout)", move |b| {
-                b.iter(|| software::render(&state, [300, 800]))
+                b.iter(|| renderer.render(&state, [300, 800]))
             });
-
-            let state = layout.state(&snapshot);
-
-            c.bench_function("Software Rendering (Subsplits Layout, 1x1)", move |b| {
-                b.iter(|| software::render(&state, [1, 1]))
-            });
-
-            let state = layout.state(&snapshot);
-
-            c.bench(
-                "Software Rendering (Subsplits Layout, Anti Aliased)",
-                Benchmark::new(
-                    "Software Rendering (Subsplits Layout, Anti Aliased)",
-                    move |b| b.iter(|| software::render_anti_aliased(&state, [300, 800], 4)),
-                )
-                .sample_size(20),
-            );
         }
 
         fn file(path: &str) -> BufReader<File> {
diff --git a/capi/Cargo.toml b/capi/Cargo.toml
index a304ae3..456f188 100644
--- a/capi/Cargo.toml
+++ b/capi/Cargo.toml
@@ -11,4 +11,5 @@ serde_json = "1.0.8"
 [features]
 default = ["image-shrinking"]
 image-shrinking = ["livesplit-core/image-shrinking"]
+software-rendering = ["livesplit-core/software-rendering"]
 wasm-web = ["livesplit-core/wasm-web"]
diff --git a/capi/cdylib/Cargo.toml b/capi/cdylib/Cargo.toml
index 325fb2a..0717648 100644
--- a/capi/cdylib/Cargo.toml
+++ b/capi/cdylib/Cargo.toml
@@ -14,4 +14,5 @@ crate-type = ["cdylib"]
 [features]
 default = ["image-shrinking"]
 image-shrinking = ["livesplit-core-capi/image-shrinking"]
+software-rendering = ["livesplit-core-capi/software-rendering"]
 wasm-web = ["livesplit-core-capi/wasm-web"]
diff --git a/capi/src/layout_editor_state.rs b/capi/src/layout_editor_state.rs
index ef1d2b4..813e839 100644
--- a/capi/src/layout_editor_state.rs
+++ b/capi/src/layout_editor_state.rs
@@ -37,9 +37,9 @@ pub extern "C" fn LayoutEditorState_component_text(
 /// * `0x01` - Can move the current component down
 #[no_mangle]
 pub extern "C" fn LayoutEditorState_buttons(this: &LayoutEditorState) -> u8 {
-    this.buttons.can_remove as u8 * 0x4
-        | this.buttons.can_move_up as u8 * 0x2
-        | this.buttons.can_move_down as u8 * 0x1
+    (this.buttons.can_remove as u8) << 2
+        | (this.buttons.can_move_up as u8) << 1
+        | this.buttons.can_move_down as u8
 }
 
 /// Returns the index of the currently selected component.
diff --git a/capi/src/lib.rs b/capi/src/lib.rs
index f17bcc9..a293610 100644
--- a/capi/src/lib.rs
+++ b/capi/src/lib.rs
@@ -1,18 +1,15 @@
-#![allow(
-    clippy::boxed_local, // FIXME: False Positive https://github.com/rust-lang/rust-clippy/issues/5542
-    clippy::missing_safety_doc,
-    non_camel_case_types,
-    non_snake_case
-)]
+#![allow(clippy::missing_safety_doc, non_camel_case_types, non_snake_case)]
 #![warn(missing_docs)]
 
 //! mod
 
-use std::cell::{Cell, RefCell};
-use std::ffi::CStr;
-use std::fs::File;
-use std::os::raw::c_char;
-use std::ptr;
+use std::{
+    cell::{Cell, RefCell},
+    ffi::CStr,
+    fs::File,
+    os::raw::c_char,
+    ptr,
+};
 
 pub mod analysis;
 pub mod atomic_date_time;
@@ -57,6 +54,7 @@ pub mod separator_component;
 pub mod separator_component_state;
 pub mod setting_value;
 pub mod shared_timer;
+pub mod software_renderer;
 pub mod splits_component;
 pub mod splits_component_state;
 pub mod sum_of_best_cleaner;
@@ -74,9 +72,11 @@ pub mod title_component;
 pub mod title_component_state;
 pub mod total_playtime_component;
 
-use crate::run_metadata_custom_variable::RunMetadataCustomVariable;
-use crate::run_metadata_speedrun_com_variable::RunMetadataSpeedrunComVariable;
-use crate::segment_history_element::SegmentHistoryElement;
+use crate::{
+    run_metadata_custom_variable::RunMetadataCustomVariable,
+    run_metadata_speedrun_com_variable::RunMetadataSpeedrunComVariable,
+    segment_history_element::SegmentHistoryElement,
+};
 use livesplit_core::{Time, TimeSpan};
 
 /// type
diff --git a/capi/src/software_renderer.rs b/capi/src/software_renderer.rs
new file mode 100644
index 0000000..78959c8
--- /dev/null
+++ b/capi/src/software_renderer.rs
@@ -0,0 +1,58 @@
+//! The software renderer allows rendering layouts entirely on the CPU. This is
+//! surprisingly fast and can be considered the default renderer.
+
+use livesplit_core::layout::LayoutState;
+
+#[cfg(feature = "software-rendering")]
+use livesplit_core::rendering::software::BorrowedSoftwareRenderer as SoftwareRenderer;
+
+#[cfg(not(feature = "software-rendering"))]
+/// dummy
+pub struct SoftwareRenderer;
+#[cfg(not(feature = "software-rendering"))]
+impl SoftwareRenderer {
+    fn new() -> Self {
+        panic!("The software renderer is not compiled in.")
+    }
+
+    fn render(&mut self, _: &LayoutState, _: &mut [u8], _: [u32; 2], _: u32) {}
+}
+
+/// type
+pub type OwnedSoftwareRenderer = Box<SoftwareRenderer>;
+
+/// Creates a new software renderer.
+#[no_mangle]
+pub extern "C" fn SoftwareRenderer_new() -> OwnedSoftwareRenderer {
+    Box::new(SoftwareRenderer::new())
+}
+
+/// drop
+#[no_mangle]
+pub extern "C" fn SoftwareRenderer_drop(this: OwnedSoftwareRenderer) {
+    drop(this);
+}
+
+/// Renders the layout state provided into the image buffer provided. The
+/// image has to be an array of RGBA8 encoded pixels (red, green, blue,
+/// alpha with each channel being an u8). Some frameworks may over allocate
+/// an image's dimensions. So an image with dimensions 100x50 may be over
+/// allocated as 128x64. In that case you provide the real dimensions of
+/// 100 and 50 as the width and height, but a stride of 128 pixels as that
+/// correlates with the real width of the underlying buffer.
+#[no_mangle]
+pub unsafe extern "C" fn SoftwareRenderer_render(
+    this: &mut SoftwareRenderer,
+    layout_state: &LayoutState,
+    data: *mut u8,
+    width: u32,
+    height: u32,
+    stride: u32,
+) {
+    this.render(
+        layout_state,
+        std::slice::from_raw_parts_mut(data, stride as usize * height as usize * 4),
+        [width, height],
+        stride,
+    );
+}
diff --git a/capi/staticlib/Cargo.toml b/capi/staticlib/Cargo.toml
index 1a06e6e..7b52fcf 100644
--- a/capi/staticlib/Cargo.toml
+++ b/capi/staticlib/Cargo.toml
@@ -14,4 +14,5 @@ crate-type = ["staticlib"]
 [features]
 default = ["image-shrinking"]
 image-shrinking = ["livesplit-core-capi/image-shrinking"]
+software-rendering = ["livesplit-core-capi/software-rendering"]
 wasm-web = ["livesplit-core-capi/wasm-web"]
diff --git a/src/layout/parser.rs b/src/layout/parser/mod.rs
similarity index 100%
rename from src/layout/parser.rs
rename to src/layout/parser/mod.rs
diff --git a/src/rendering/component/detailed_timer.rs b/src/rendering/component/detailed_timer.rs
index a7ff308..2a54637 100644
--- a/src/rendering/component/detailed_timer.rs
+++ b/src/rendering/component/detailed_timer.rs
@@ -2,8 +2,8 @@ use crate::{
     component::detailed_timer::State,
     layout::LayoutState,
     rendering::{
-        component::timer, icon::Icon, vertical_padding, Backend, RenderContext, BOTH_PADDINGS,
-        PADDING,
+        component::timer, icon::Icon, solid, vertical_padding, Backend, RenderContext,
+        BOTH_PADDINGS, PADDING,
     },
 };
 
@@ -12,16 +12,18 @@ pub(in crate::rendering) fn render<B: Backend>(
     [width, height]: [f32; 2],
     component: &State,
     layout_state: &LayoutState,
-    detailed_timer_icon: &mut Option<Icon<B::Texture>>,
+    detailed_timer_icon: &mut Option<Icon<B::Image>>,
 ) {
     context.render_rectangle([0.0, 0.0], [width, height], &component.background);
 
+    let text_color = solid(&layout_state.text_color);
+
     let vertical_padding = vertical_padding(height);
     let icon_size = height - 2.0 * vertical_padding;
 
     if let Some(icon) = &component.icon_change {
         if let Some(old_icon) = detailed_timer_icon.take() {
-            context.backend.free_texture(old_icon.texture);
+            context.backend.free_image(old_icon.image);
         }
         *detailed_timer_icon = context.create_icon(&icon);
     }
@@ -43,7 +45,7 @@ pub(in crate::rendering) fn render<B: Backend>(
             &segment_name,
             [left_side, 0.6 * top_height],
             0.5 * top_height,
-            [layout_state.text_color; 2],
+            text_color,
             timer_end,
         );
     }
@@ -66,7 +68,7 @@ pub(in crate::rendering) fn render<B: Backend>(
                 &comparison.name,
                 [left_side, comparison2_y],
                 comparison_text_scale,
-                [layout_state.text_color; 2],
+                text_color,
                 segment_timer_end,
             )
             .max(name_end);
@@ -86,7 +88,7 @@ pub(in crate::rendering) fn render<B: Backend>(
                 &comparison.name,
                 [left_side, comparison1_y],
                 comparison_text_scale,
-                [layout_state.text_color; 2],
+                text_color,
                 segment_timer_end,
             )
             .max(name_end);
@@ -103,7 +105,7 @@ pub(in crate::rendering) fn render<B: Backend>(
             &comparison.time,
             [time_x, comparison2_y],
             comparison_text_scale,
-            [layout_state.text_color; 2],
+            text_color,
         );
     }
     if let Some(comparison) = &component.comparison1 {
@@ -111,7 +113,7 @@ pub(in crate::rendering) fn render<B: Backend>(
             &comparison.time,
             [time_x, comparison1_y],
             comparison_text_scale,
-            [layout_state.text_color; 2],
+            text_color,
         );
     }
 }
diff --git a/src/rendering/component/graph.rs b/src/rendering/component/graph.rs
index 4ec4e85..3d788b2 100644
--- a/src/rendering/component/graph.rs
+++ b/src/rendering/component/graph.rs
@@ -1,18 +1,8 @@
-use {
-    crate::{
-        component::graph::State,
-        layout::LayoutState,
-        rendering::{
-            mesh::{basic_builder, fill_builder, stroke_builder},
-            Backend, Mesh, RenderContext,
-        },
-        settings::Gradient,
-    },
-    lyon::tessellation::{
-        basic_shapes::{fill_circle, fill_polyline, stroke_polyline},
-        FillOptions, FillTessellator, StrokeOptions,
-    },
-    std::iter,
+use crate::{
+    component::graph::State,
+    layout::LayoutState,
+    rendering::{Backend, PathBuilder, RenderContext},
+    settings::Gradient,
 };
 
 pub(in crate::rendering) fn render(
@@ -56,71 +46,40 @@ pub(in crate::rendering) fn render(
         );
     }
 
-    let mut mesh = Mesh::new();
-
     let len = if component.is_live_delta_active {
         let p1 = &component.points[component.points.len() - 2];
         let p2 = &component.points[component.points.len() - 1];
 
-        fill_polyline(
-            [
-                [p1.x, component.middle],
-                [p1.x, p1.y],
-                [p2.x, p2.y],
-                [p2.x, component.middle],
-            ]
-            .iter()
-            .map(|&[x, y]| [width * x, y].into()),
-            &mut FillTessellator::new(),
-            &FillOptions::tolerance(0.005),
-            &mut fill_builder(&mut mesh),
-        )
-        .unwrap();
-
-        let partial_fill_mesh = context.create_mesh(&mesh);
-        context.render_mesh(&partial_fill_mesh, component.partial_fill_color);
-        context.free_mesh(partial_fill_mesh);
-
-        mesh.clear();
+        let mut builder = context.backend.path_builder();
+        builder.move_to(width * p1.x, component.middle);
+        builder.line_to(width * p1.x, p1.y);
+        builder.line_to(width * p2.x, p2.y);
+        builder.line_to(width * p2.x, component.middle);
+        builder.close();
+        let partial_fill_path = builder.finish(context.backend);
+        context.render_path(&partial_fill_path, component.partial_fill_color);
+        context.free_path(partial_fill_path);
 
         component.points.len() - 1
     } else {
         component.points.len()
     };
 
-    fill_polyline(
-        iter::once([0.0, component.middle].into())
-            .chain(
-                component.points[..len]
-                    .iter()
-                    .map(|p| [width * p.x, p.y].into()),
-            )
-            .chain(iter::once(
-                [width * component.points[len - 1].x, component.middle].into(),
-            )),
-        &mut FillTessellator::new(),
-        &FillOptions::tolerance(0.005),
-        &mut fill_builder(&mut mesh),
-    )
-    .unwrap();
-
-    let fill_mesh = context.create_mesh(&mesh);
-    context.render_mesh(&fill_mesh, component.complete_fill_color);
-    context.free_mesh(fill_mesh);
+    let mut builder = context.backend.path_builder();
+    builder.move_to(0.0, component.middle);
+    for p in &component.points[..len] {
+        builder.line_to(width * p.x, p.y);
+    }
+    builder.line_to(width * component.points[len - 1].x, component.middle);
+    builder.close();
+    let fill_path = builder.finish(context.backend);
+    context.render_path(&fill_path, component.complete_fill_color);
+    context.free_path(fill_path);
 
     for points in component.points.windows(2) {
-        mesh.clear();
-
-        let p1 = [width * points[0].x, points[0].y].into();
-        let p2 = [width * points[1].x, points[1].y].into();
-
-        stroke_polyline(
-            iter::once(p1).chain(iter::once(p2)),
-            false,
-            &StrokeOptions::default().with_line_width(LINE_WIDTH),
-            &mut stroke_builder(&mut mesh),
-        )
-        .unwrap();
+        let mut builder = context.backend.path_builder();
+        builder.move_to(width * points[0].x, points[0].y);
+        builder.line_to(width * points[1].x, points[1].y);
 
         let color = if points[1].is_best_segment {
             component.best_segment_color
@@ -128,32 +87,24 @@ pub(in crate::rendering) fn render(
             component.graph_lines_color
         };
 
-        let line_mesh = context.create_mesh(&mesh);
-        context.render_mesh(&line_mesh, color);
-        context.free_mesh(line_mesh);
+        let line_path = builder.finish(context.backend);
+        context.render_stroke_path(&line_path, color, LINE_WIDTH);
+        context.free_path(line_path);
     }
 
     for (i, point) in component.points.iter().enumerate().skip(1) {
         if i != component.points.len() - 1 || !component.is_live_delta_active {
-            mesh.clear();
-
-            fill_circle(
-                [width * point.x, point.y].into(),
-                CIRCLE_RADIUS,
-                &FillOptions::tolerance(0.005),
-                &mut basic_builder(&mut mesh),
-            )
-            .unwrap();
-
             let color = if point.is_best_segment {
                 component.best_segment_color
             } else {
                 component.graph_lines_color
             };
 
-            let circle_mesh = context.create_mesh(&mesh);
-            context.render_mesh(&circle_mesh, color);
-            context.free_mesh(circle_mesh);
+            let circle_path = context
+                .backend
+                .build_circle(width * point.x, point.y, CIRCLE_RADIUS);
+            context.render_path(&circle_path, color);
+            context.free_path(circle_path);
         }
     }
 
diff --git a/src/rendering/component/splits.rs b/src/rendering/component/splits.rs
index 9815c05..90c4ed3 100644
--- a/src/rendering/component/splits.rs
+++ b/src/rendering/component/splits.rs
@@ -2,22 +2,24 @@ use crate::{
     component::splits::State,
     layout::{LayoutDirection, LayoutState},
     rendering::{
-        icon::Icon, vertical_padding, Backend, RenderContext, BOTH_PADDINGS,
+        icon::Icon, solid, vertical_padding, Backend, RenderContext, BOTH_PADDINGS,
         DEFAULT_COMPONENT_HEIGHT, DEFAULT_TEXT_SIZE, PADDING, TEXT_ALIGN_BOTTOM, TEXT_ALIGN_TOP,
         THIN_SEPARATOR_THICKNESS, TWO_ROW_HEIGHT,
     },
     settings::{Gradient, ListGradient},
 };
 
-pub const COLUMN_WIDTH: f32 = 3.0;
+pub const COLUMN_WIDTH: f32 = 2.75;
 
 pub(in crate::rendering) fn render<B: Backend>(
     context: &mut RenderContext<'_, B>,
     [width, height]: [f32; 2],
     component: &State,
     layout_state: &LayoutState,
-    split_icons: &mut Vec<Option<Icon<B::Texture>>>,
+    split_icons: &mut Vec<Option<Icon<B::Image>>>,
 ) {
+    let text_color = solid(&layout_state.text_color);
+
     let split_background = match component.background {
         ListGradient::Same(gradient) => {
             context.render_rectangle([0.0, 0.0], [width, height], &gradient);
@@ -65,7 +67,7 @@ pub(in crate::rendering) fn render<B: Backend>(
         }
         let icon = &mut split_icons[icon_change.segment_index];
         if let Some(old_icon) = icon.take() {
-            context.backend.free_texture(old_icon.texture);
+            context.backend.free_image(old_icon.image);
         }
         *icon = context.create_icon(&icon_change.icon);
     }
@@ -79,7 +81,7 @@ pub(in crate::rendering) fn render<B: Backend>(
                     label,
                     [right_x, TEXT_ALIGN_TOP],
                     DEFAULT_TEXT_SIZE,
-                    [layout_state.text_color; 2],
+                    text_color,
                 );
                 right_x = left_x;
             }
@@ -133,7 +135,7 @@ pub(in crate::rendering) fn render<B: Backend>(
                         &column.value,
                         [right_x, split_height + TEXT_ALIGN_BOTTOM],
                         DEFAULT_TEXT_SIZE,
-                        [column.visual_color; 2],
+                        solid(&column.visual_color),
                     );
                 }
                 right_x -= COLUMN_WIDTH;
@@ -147,7 +149,7 @@ pub(in crate::rendering) fn render<B: Backend>(
                 &split.name,
                 [icon_right, TEXT_ALIGN_TOP],
                 DEFAULT_TEXT_SIZE,
-                [layout_state.text_color; 2],
+                text_color,
                 left_x - PADDING,
             );
         }
diff --git a/src/rendering/component/text.rs b/src/rendering/component/text.rs
index 8cca984..160184a 100644
--- a/src/rendering/component/text.rs
+++ b/src/rendering/component/text.rs
@@ -1,7 +1,7 @@
 use crate::{
     component::text::{State, TextState},
     layout::{LayoutDirection, LayoutState},
-    rendering::{Backend, RenderContext, DEFAULT_TEXT_SIZE, PADDING, TEXT_ALIGN_TOP},
+    rendering::{solid, Backend, RenderContext, DEFAULT_TEXT_SIZE, PADDING, TEXT_ALIGN_TOP},
 };
 
 pub(in crate::rendering) fn render(
@@ -18,9 +18,11 @@ pub(in crate::rendering) fn render(
             width - PADDING,
             [0.5 * width, TEXT_ALIGN_TOP],
             DEFAULT_TEXT_SIZE,
-            component
-                .left_center_color
-                .unwrap_or(layout_state.text_color),
+            solid(
+                &component
+                    .left_center_color
+                    .unwrap_or(layout_state.text_color),
+            ),
         ),
         TextState::Split(left, right) => context.render_key_value_component(
             &left,
diff --git a/src/rendering/component/timer.rs b/src/rendering/component/timer.rs
index e9f0f60..b4f75a5 100644
--- a/src/rendering/component/timer.rs
+++ b/src/rendering/component/timer.rs
@@ -1,6 +1,6 @@
 use crate::{
     component::timer::State,
-    rendering::{Backend, RenderContext, DEFAULT_TEXT_SIZE, PADDING},
+    rendering::{decode_color, Backend, FillShader, RenderContext, PADDING},
 };
 
 pub(in crate::rendering) fn render(
@@ -9,16 +9,15 @@ pub(in crate::rendering) fn render(
     component: &State,
 ) -> f32 {
     context.render_rectangle([0.0, 0.0], [width, height], &component.background);
+    let shader = FillShader::VerticalGradient(
+        decode_color(&component.top_color),
+        decode_color(&component.bottom_color),
+    );
     let x = context.render_timer(
         &component.fraction,
         [width - PADDING, 0.85 * height],
-        DEFAULT_TEXT_SIZE * height,
-        [component.bottom_color, component.top_color],
+        0.8 * height,
+        shader,
     );
-    context.render_timer(
-        &component.time,
-        [x, 0.85 * height],
-        1.2 * height,
-        [component.bottom_color, component.top_color],
-    )
+    context.render_timer(&component.time, [x, 0.85 * height], 1.2 * height, shader)
 }
diff --git a/src/rendering/component/title.rs b/src/rendering/component/title.rs
index 37f6a14..474a3e4 100644
--- a/src/rendering/component/title.rs
+++ b/src/rendering/component/title.rs
@@ -2,8 +2,8 @@ use crate::{
     component::title::State,
     layout::LayoutState,
     rendering::{
-        icon::Icon, vertical_padding, Backend, RenderContext, BOTH_PADDINGS, DEFAULT_TEXT_SIZE,
-        PADDING, TEXT_ALIGN_BOTTOM, TEXT_ALIGN_CENTER, TEXT_ALIGN_TOP,
+        icon::Icon, solid, vertical_padding, Backend, RenderContext, BOTH_PADDINGS,
+        DEFAULT_TEXT_SIZE, PADDING, TEXT_ALIGN_BOTTOM, TEXT_ALIGN_CENTER, TEXT_ALIGN_TOP,
     },
 };
 
@@ -12,14 +12,15 @@ pub(in crate::rendering) fn render<B: Backend>(
     [width, height]: [f32; 2],
     component: &State,
     layout_state: &LayoutState,
-    game_icon: &mut Option<Icon<B::Texture>>,
+    game_icon: &mut Option<Icon<B::Image>>,
 ) {
     context.render_rectangle([0.0, 0.0], [width, height], &component.background);
     let text_color = component.text_color.unwrap_or(layout_state.text_color);
+    let text_color = solid(&text_color);
 
     if let Some(icon) = &component.icon_change {
         if let Some(old_icon) = game_icon.take() {
-            context.backend.free_texture(old_icon.texture);
+            context.backend.free_image(old_icon.image);
         }
         *game_icon = context.create_icon(icon);
     }
@@ -48,7 +49,7 @@ pub(in crate::rendering) fn render<B: Backend>(
         &attempts,
         [width - PADDING, height + TEXT_ALIGN_BOTTOM],
         DEFAULT_TEXT_SIZE,
-        [text_color; 2],
+        text_color,
     ) - PADDING;
 
     let (line1_y, line1_end_x) = if !component.line2.is_empty() {
diff --git a/src/rendering/fonts/FiraSans-Regular.ttf b/src/rendering/font/assets/FiraSans-Regular.ttf
similarity index 100%
rename from src/rendering/fonts/FiraSans-Regular.ttf
rename to src/rendering/font/assets/FiraSans-Regular.ttf
diff --git a/src/rendering/fonts/Timer.ttf b/src/rendering/font/assets/Timer.ttf
similarity index 100%
rename from src/rendering/fonts/Timer.ttf
rename to src/rendering/font/assets/Timer.ttf
diff --git a/src/rendering/font/color_font/colr.rs b/src/rendering/font/color_font/colr.rs
new file mode 100644
index 0000000..7c25d79
--- /dev/null
+++ b/src/rendering/font/color_font/colr.rs
@@ -0,0 +1,60 @@
+use super::parse_util::{pod, slice, O32, U16};
+use bytemuck::{Pod, Zeroable};
+
+#[derive(Debug, Copy, Clone, Pod, Zeroable)]
+#[repr(C)]
+struct Header {
+    version: U16,
+    num_base_glyph_records: U16,
+    base_glyph_records_offset: O32,
+    layer_records_offset: O32,
+    num_layer_records: U16,
+}
+
+#[derive(Debug, Copy, Clone, Pod, Zeroable)]
+#[repr(C)]
+struct BaseGlyphRecord {
+    glyph_id: U16,
+    first_layer_idx: U16,
+    num_layers: U16,
+}
+
+#[derive(Debug, Copy, Clone, Pod, Zeroable)]
+#[repr(C)]
+pub struct LayerRecord {
+    glyph_id: U16,
+    palette_entry_idx: U16,
+}
+
+impl LayerRecord {
+    pub fn glyph_id(&self) -> u16 {
+        self.glyph_id.get()
+    }
+
+    pub fn palette_entry_idx(&self) -> u16 {
+        self.palette_entry_idx.get()
+    }
+}
+
+pub fn look_up(table: &[u8], glyph: u16) -> Option<&[LayerRecord]> {
+    let header = pod::<Header>(table)?;
+
+    let base_glyphs = slice::<BaseGlyphRecord>(
+        table.get(header.base_glyph_records_offset.usize()..)?,
+        header.num_base_glyph_records.usize(),
+    )?;
+
+    let glyph = glyph.to_be_bytes();
+    let base_glyph = &base_glyphs[base_glyphs
+        .binary_search_by_key(&glyph, |b| b.glyph_id.0)
+        .ok()?];
+
+    let layer_records = slice(
+        table.get(header.layer_records_offset.usize()..)?,
+        header.num_layer_records.usize(),
+    )?;
+
+    layer_records
+        .get(base_glyph.first_layer_idx.usize()..)?
+        .get(..base_glyph.num_layers.usize())
+}
diff --git a/src/rendering/font/color_font/cpal.rs b/src/rendering/font/color_font/cpal.rs
new file mode 100644
index 0000000..72bf1e6
--- /dev/null
+++ b/src/rendering/font/color_font/cpal.rs
@@ -0,0 +1,45 @@
+use std::mem;
+
+use super::parse_util::{pod, slice, O32, U16};
+use bytemuck::{Pod, Zeroable};
+
+#[derive(Debug, Copy, Clone, Pod, Zeroable)]
+#[repr(C)]
+struct Header {
+    version: U16,
+    num_palette_entries: U16,
+    num_palettes: U16,
+    num_color_records: U16,
+    color_records_array_offset: O32,
+}
+
+#[derive(Debug, Copy, Clone, Pod, Zeroable)]
+#[repr(C)]
+struct HeaderV1Extra {
+    palette_types_array_offset: O32,
+    palette_labels_array_offset: O32,
+    palette_entry_labels_array_offset: O32,
+}
+
+#[derive(Debug, Copy, Clone, Pod, Zeroable)]
+#[repr(C)]
+pub struct ColorRecord {
+    pub blue: u8,
+    pub green: u8,
+    pub red: u8,
+    pub alpha: u8,
+}
+
+pub fn look_up(table: &[u8], palette_idx: usize, palette_entry_idx: u16) -> Option<&ColorRecord> {
+    let header = pod::<Header>(table)?;
+    let color_record_indices = slice::<U16>(
+        table.get(mem::size_of::<Header>()..)?,
+        header.num_palettes.usize(),
+    )?;
+    let color_records = slice::<ColorRecord>(
+        table.get(header.color_records_array_offset.usize()..)?,
+        header.num_color_records.usize(),
+    )?;
+    let color_record_index = color_record_indices.get(palette_idx)?.usize();
+    color_records.get(color_record_index + palette_entry_idx as usize)
+}
diff --git a/src/rendering/font/color_font/mod.rs b/src/rendering/font/color_font/mod.rs
new file mode 100644
index 0000000..f32b611
--- /dev/null
+++ b/src/rendering/font/color_font/mod.rs
@@ -0,0 +1,57 @@
+use ttf_parser::{Face, Tag};
+
+use crate::settings::Color;
+
+mod colr;
+mod cpal;
+mod parse_util;
+
+pub struct ColorTables<'f> {
+    colr: &'f [u8],
+    cpal: &'f [u8],
+}
+
+impl<'f> ColorTables<'f> {
+    pub fn new(face: &Face<'f>) -> Option<Self> {
+        Some(Self {
+            colr: face.table_data(Tag::from_bytes(b"COLR"))?,
+            cpal: face.table_data(Tag::from_bytes(b"CPAL"))?,
+        })
+    }
+
+    pub fn look_up(
+        &self,
+        palette: usize,
+        glyph: u16,
+    ) -> Option<impl Iterator<Item = (u16, Option<Color>)> + '_> {
+        let layers = colr::look_up(self.colr, glyph)?;
+        Some(layers.iter().map(move |layer| {
+            let entry_idx = layer.palette_entry_idx();
+            let color = if entry_idx != 0xFFFF {
+                cpal::look_up(self.cpal, palette, entry_idx)
+                    .map(|col| [col.red, col.green, col.blue, col.alpha].into())
+            } else {
+                None
+            };
+            (layer.glyph_id(), color)
+        }))
+    }
+}
+
+pub fn iter_colored_glyphs(
+    color_tables: &Option<ColorTables<'_>>,
+    palette: usize,
+    glyph: u16,
+    mut f: impl FnMut(u16, Option<Color>),
+) {
+    if let Some(iter) = color_tables
+        .as_ref()
+        .and_then(|c| c.look_up(palette, glyph))
+    {
+        for (glyph, color) in iter {
+            f(glyph, color);
+        }
+    } else {
+        f(glyph, None)
+    }
+}
diff --git a/src/rendering/font/color_font/parse_util.rs b/src/rendering/font/color_font/parse_util.rs
new file mode 100644
index 0000000..cb9831b
--- /dev/null
+++ b/src/rendering/font/color_font/parse_util.rs
@@ -0,0 +1,50 @@
+use bytemuck::{Pod, Zeroable};
+use std::{fmt, mem};
+
+#[derive(Copy, Clone, Pod, Zeroable)]
+#[repr(transparent)]
+pub struct U16(pub [u8; 2]);
+
+impl fmt::Debug for U16 {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        fmt::Debug::fmt(&self.get(), f)
+    }
+}
+
+impl U16 {
+    pub fn get(self) -> u16 {
+        u16::from_be_bytes(self.0)
+    }
+
+    pub fn usize(self) -> usize {
+        self.get() as usize
+    }
+}
+
+#[derive(Copy, Clone, Pod, Zeroable)]
+#[repr(transparent)]
+pub struct O32(pub [u8; 4]);
+
+impl fmt::Debug for O32 {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        fmt::Debug::fmt(&self.get(), f)
+    }
+}
+
+impl O32 {
+    pub fn get(self) -> u32 {
+        u32::from_be_bytes(self.0)
+    }
+
+    pub fn usize(self) -> usize {
+        self.get() as usize
+    }
+}
+
+pub fn pod<P: Pod>(bytes: &[u8]) -> Option<&P> {
+    Some(bytemuck::from_bytes(bytes.get(..mem::size_of::<P>())?))
+}
+
+pub fn slice<P: Pod>(bytes: &[u8], n: usize) -> Option<&[P]> {
+    Some(bytemuck::cast_slice(bytes.get(..n * mem::size_of::<P>())?))
+}
diff --git a/src/rendering/font/glyph_cache.rs b/src/rendering/font/glyph_cache.rs
new file mode 100644
index 0000000..ffa0545
--- /dev/null
+++ b/src/rendering/font/glyph_cache.rs
@@ -0,0 +1,72 @@
+use std::marker::PhantomData;
+
+use crate::settings::Color;
+
+use super::{color_font::iter_colored_glyphs, Backend, Font};
+use hashbrown::HashMap;
+use ttf_parser::OutlineBuilder;
+
+struct PathBuilder<B, PB>(PB, PhantomData<fn(&mut B)>);
+
+impl<B, PB: super::super::PathBuilder<B>> OutlineBuilder for PathBuilder<B, PB> {
+    fn move_to(&mut self, x: f32, y: f32) {
+        self.0.move_to(x, -y);
+    }
+    fn line_to(&mut self, x: f32, y: f32) {
+        self.0.line_to(x, -y);
+    }
+    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
+        self.0.quad_to(x1, -y1, x, -y);
+    }
+    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
+        self.0.curve_to(x1, -y1, x2, -y2, x, -y);
+    }
+    fn close(&mut self) {
+        self.0.close();
+    }
+}
+
+pub struct GlyphCache<P> {
+    glyphs: HashMap<u32, Vec<(Option<Color>, P)>>,
+}
+
+impl<P> Default for GlyphCache<P> {
+    fn default() -> Self {
+        Self {
+            glyphs: Default::default(),
+        }
+    }
+}
+
+impl<P> GlyphCache<P> {
+    pub fn new() -> Self {
+        Default::default()
+    }
+
+    #[cfg(feature = "font-loading")]
+    pub fn clear(&mut self, backend: &mut impl Backend<Path = P>) {
+        for (_, glyph) in self.glyphs.drain() {
+            for (_, layer) in glyph {
+                backend.free_path(layer);
+            }
+        }
+    }
+
+    pub fn lookup_or_insert(
+        &mut self,
+        font: &Font<'_>,
+        glyph: u32,
+        backend: &mut impl Backend<Path = P>,
+    ) -> &[(Option<Color>, P)] {
+        self.glyphs.entry(glyph).or_insert_with(|| {
+            let mut glyphs = Vec::new();
+            iter_colored_glyphs(&font.color_tables, 0, glyph as _, |glyph, color| {
+                let mut builder = PathBuilder(backend.path_builder(), PhantomData);
+                font.outline_glyph(glyph, &mut builder);
+                let path = super::super::PathBuilder::finish(builder.0, backend);
+                glyphs.push((color, path));
+            });
+            glyphs
+        })
+    }
+}
diff --git a/src/rendering/font.rs b/src/rendering/font/mod.rs
similarity index 88%
rename from src/rendering/font.rs
rename to src/rendering/font/mod.rs
index 5da16d5..80a9527 100644
--- a/src/rendering/font.rs
+++ b/src/rendering/font/mod.rs
@@ -1,21 +1,32 @@
-use super::{decode_color, glyph_cache::GlyphCache, Backend, Pos, Transform};
-use crate::settings::{Color, FontStretch, FontStyle, FontWeight};
-#[cfg(feature = "font-loading")]
-use font_kit::properties::{Stretch, Style, Weight};
+mod color_font;
+mod glyph_cache;
+
+use self::color_font::ColorTables;
+use super::{solid, Backend, FillShader, Pos, Transform};
+use crate::settings::{FontStretch, FontStyle, FontWeight};
 use rustybuzz::{Face, Feature, GlyphBuffer, Tag, UnicodeBuffer, Variation};
-use ttf_parser::{GlyphId, OutlineBuilder};
+use ttf_parser::{GlyphId, OutlineBuilder, Tag as ParserTag};
+
+pub use self::glyph_cache::GlyphCache;
 
 #[cfg(feature = "font-loading")]
 use {
     font_kit::{
-        family_name::FamilyName, handle::Handle, properties::Properties, source::SystemSource,
+        family_name::FamilyName,
+        handle::Handle,
+        properties::{Properties, Stretch, Style, Weight},
+        source::SystemSource,
     },
     std::{fs, sync::Arc},
 };
 
+pub const TEXT_FONT: &[u8] = include_bytes!("assets/FiraSans-Regular.ttf");
+pub const TIMER_FONT: &[u8] = include_bytes!("assets/Timer.ttf");
+
 pub struct Font<'fd> {
     rb: Face<'fd>,
     face: ttf_parser::Face<'fd>,
+    color_tables: Option<ColorTables<'fd>>,
     scale_factor: f32,
     #[cfg(feature = "font-loading")]
     /// Safety: This can never be mutated. This also needs to be dropped last.
@@ -67,16 +78,16 @@ impl<'fd> Font<'fd> {
         weight: FontWeight,
         stretch: FontStretch,
     ) -> Option<Self> {
-        let mut parser = ttf_parser::Face::from_slice(data, index).ok()?;
+        let mut face = ttf_parser::Face::from_slice(data, index).ok()?;
         let mut rb = Face::from_slice(data, index)?;
 
         let italic = style.value_for_italic();
         let weight = weight.value();
         let stretch = stretch.percentage();
 
-        parser.set_variation(Tag::from_bytes(b"ital"), italic);
-        parser.set_variation(Tag::from_bytes(b"wght"), weight);
-        parser.set_variation(Tag::from_bytes(b"wdth"), stretch);
+        face.set_variation(ParserTag::from_bytes(b"ital"), italic);
+        face.set_variation(ParserTag::from_bytes(b"wght"), weight);
+        face.set_variation(ParserTag::from_bytes(b"wdth"), stretch);
 
         rb.set_variations(&[
             Variation {
@@ -94,9 +105,10 @@ impl<'fd> Font<'fd> {
         ]);
 
         Some(Self {
-            scale_factor: 1.0 / parser.height() as f32,
+            scale_factor: 1.0 / face.height() as f32,
             rb,
-            face: parser,
+            color_tables: ColorTables::new(&face),
+            face,
             #[cfg(feature = "font-loading")]
             buf: None,
         })
@@ -109,19 +121,11 @@ impl<'fd> Font<'fd> {
         }
     }
 
-    pub fn outline_glyph(&self, glyph_id: u32, builder: &mut dyn OutlineBuilder) -> bool {
+    pub fn outline_glyph(&self, glyph_id: u16, builder: &mut dyn OutlineBuilder) -> bool {
         self.face
-            .outline_glyph(GlyphId(glyph_id as u16), builder)
+            .outline_glyph(GlyphId(glyph_id), builder)
             .is_some()
     }
-
-    pub fn descender(&self) -> f32 {
-        self.face.descender() as f32
-    }
-
-    pub fn height(&self) -> f32 {
-        self.face.height() as f32
-    }
 }
 
 #[derive(Copy, Clone)]
@@ -379,23 +383,21 @@ impl<'f> Glyphs<'f> {
 
 pub fn render<B: Backend>(
     layout: impl IntoIterator<Item = PositionedGlyph>,
-    [top, bottom]: [Color; 2],
+    shader: FillShader,
     font: &ScaledFont<'_>,
-    glyph_cache: &mut GlyphCache<B::Mesh>,
+    glyph_cache: &mut GlyphCache<B::Path>,
     transform: &Transform,
     backend: &mut B,
 ) {
-    let top = decode_color(&top);
-    let bottom = decode_color(&bottom);
-    let colors = [top, top, bottom, bottom];
-
     for glyph in layout {
-        let glyph_mesh = glyph_cache.lookup_or_insert(font.font, glyph.id, backend);
+        let layers = glyph_cache.lookup_or_insert(font.font, glyph.id, backend);
 
         let transform = transform
             .pre_translate([glyph.x, glyph.y].into())
             .pre_scale(font.scale, font.scale);
 
-        backend.render_mesh(glyph_mesh, transform, colors, None);
+        for (color, layer) in layers {
+            backend.render_fill_path(layer, color.as_ref().map_or(shader, solid), transform);
+        }
     }
 }
diff --git a/src/rendering/glyph_cache.rs b/src/rendering/glyph_cache.rs
deleted file mode 100644
index 61df143..0000000
--- a/src/rendering/glyph_cache.rs
+++ /dev/null
@@ -1,90 +0,0 @@
-use super::{
-    font::Font,
-    mesh::{fill_builder, Mesh},
-    Backend,
-};
-use hashbrown::HashMap;
-use lyon::{
-    path::{self, math::point, Path},
-    tessellation::{FillOptions, FillTessellator},
-};
-use ttf_parser::OutlineBuilder;
-
-struct PathBuilder(path::Builder);
-
-impl OutlineBuilder for PathBuilder {
-    fn move_to(&mut self, x: f32, y: f32) {
-        self.0.move_to(point(x, -y));
-    }
-    fn line_to(&mut self, x: f32, y: f32) {
-        self.0.line_to(point(x, -y));
-    }
-    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
-        self.0.quadratic_bezier_to(point(x1, -y1), point(x, -y));
-    }
-    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
-        self.0
-            .cubic_bezier_to(point(x1, -y1), point(x2, -y2), point(x, -y));
-    }
-    fn close(&mut self) {
-        self.0.close();
-    }
-}
-
-pub struct GlyphCache<M> {
-    glyphs: HashMap<u32, M>,
-}
-
-impl<M> Default for GlyphCache<M> {
-    fn default() -> Self {
-        Self {
-            glyphs: Default::default(),
-        }
-    }
-}
-
-impl<M> GlyphCache<M> {
-    pub fn new() -> Self {
-        Default::default()
-    }
-
-    #[cfg(feature = "font-loading")]
-    pub fn clear(&mut self, backend: &mut impl Backend<Mesh = M>) {
-        for (_, mesh) in self.glyphs.drain() {
-            backend.free_mesh(mesh);
-        }
-    }
-
-    pub fn lookup_or_insert(
-        &mut self,
-        font: &Font<'_>,
-        glyph: u32,
-        backend: &mut impl Backend<Mesh = M>,
-    ) -> &M {
-        self.glyphs.entry(glyph).or_insert_with(|| {
-            let mut builder = PathBuilder(Path::builder());
-            let mut glyph_mesh = Mesh::new();
-
-            if font.outline_glyph(glyph, &mut builder) {
-                let path = builder.0.build();
-                let mut tessellator = FillTessellator::new();
-                tessellator
-                    .tessellate_path(
-                        &path,
-                        &FillOptions::tolerance(0.005),
-                        &mut fill_builder(&mut glyph_mesh),
-                    )
-                    .unwrap();
-
-                let scale = -font.height().recip();
-                let descender = scale * font.descender();
-
-                for vertex in glyph_mesh.vertices_mut() {
-                    vertex.v = descender + scale * vertex.y;
-                }
-            }
-
-            backend.create_mesh(&glyph_mesh)
-        })
-    }
-}
diff --git a/src/rendering/icon.rs b/src/rendering/icon.rs
index 9a7da4b..9c69e02 100644
--- a/src/rendering/icon.rs
+++ b/src/rendering/icon.rs
@@ -1,4 +1,4 @@
 pub struct Icon<T> {
-    pub texture: T,
+    pub image: T,
     pub aspect_ratio: f32,
 }
diff --git a/src/rendering/mesh.rs b/src/rendering/mesh.rs
deleted file mode 100644
index 669b414..0000000
--- a/src/rendering/mesh.rs
+++ /dev/null
@@ -1,139 +0,0 @@
-use lyon::tessellation::{
-    geometry_builder::{
-        vertex_builder, BasicGeometryBuilder, FillGeometryBuilder, StrokeGeometryBuilder,
-    },
-    math::Point,
-    FillAttributes, StrokeAttributes, VertexBuffers,
-};
-
-/// The vertex types describes a single point of a mesh used to form triangles.
-/// It uses a C compatible layout such that it can be directly uploaded to a GPU.
-#[repr(C)]
-#[derive(Copy, Clone, PartialOrd, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
-pub struct Vertex {
-    /// The x coordinate of the vertex.
-    pub x: f32,
-    /// The y coordinate of the vertex.
-    pub y: f32,
-    /// The u coordinate of the vertex, which corresponds to the x axis in
-    /// texture space.
-    pub u: f32,
-    /// The v coordinate of the vertex, which corresponds to the y axis in
-    /// texture space.
-    pub v: f32,
-}
-
-/// A mesh supplied to the backend that will eventually be rendered out.
-pub struct Mesh {
-    pub(in crate::rendering) buffers: VertexBuffers<Vertex, u16>,
-}
-
-impl Mesh {
-    pub(in crate::rendering) fn new() -> Self {
-        Self {
-            buffers: VertexBuffers::new(),
-        }
-    }
-
-    pub(in crate::rendering) fn clear(&mut self) {
-        self.buffers.vertices.clear();
-        self.buffers.indices.clear();
-    }
-
-    pub(in crate::rendering) fn vertices_mut(&mut self) -> &mut [Vertex] {
-        &mut self.buffers.vertices
-    }
-
-    /// The vertices that make up the mesh.
-    pub fn vertices(&self) -> &[Vertex] {
-        &self.buffers.vertices
-    }
-
-    /// The indices describe the actual triangles that make up the mesh. Each
-    /// chunk of three indices pointing into the `vertices` makes up a triangle.
-    pub fn indices(&self) -> &[u16] {
-        &self.buffers.indices
-    }
-
-    /// The vertices that make up the mesh. This method returns the vertices as
-    /// a slice of bytes, which may be easier to consume for various graphics
-    /// APIs.
-    pub fn vertices_as_bytes(&self) -> &[u8] {
-        bytemuck::cast_slice(self.vertices())
-    }
-
-    /// The indices describe the actual triangles that make up the mesh. Each
-    /// chunk of three indices pointing into the `vertices` makes up a triangle.
-    /// This method returns the indices as a slice of bytes, which may be easier
-    /// to consume for various graphics APIs.
-    pub fn indices_as_bytes(&self) -> &[u8] {
-        bytemuck::cast_slice(self.indices())
-    }
-}
-
-pub fn stroke_builder(mesh: &mut Mesh) -> impl StrokeGeometryBuilder + '_ {
-    vertex_builder::<_, _, (), _>(
-        &mut mesh.buffers,
-        |p: Point, _: StrokeAttributes<'_, '_>| Vertex {
-            x: p.x,
-            y: p.y,
-            u: 0.0,
-            v: 0.0,
-        },
-    )
-}
-
-pub fn fill_builder(mesh: &mut Mesh) -> impl FillGeometryBuilder + '_ {
-    vertex_builder::<_, _, (), _>(&mut mesh.buffers, |p: Point, _: FillAttributes<'_>| {
-        Vertex {
-            x: p.x,
-            y: p.y,
-            u: 0.0,
-            v: 0.0,
-        }
-    })
-}
-
-pub fn basic_builder(mesh: &mut Mesh) -> impl BasicGeometryBuilder + '_ {
-    vertex_builder::<_, _, (), _>(&mut mesh.buffers, |p: Point| Vertex {
-        x: p.x,
-        y: p.y,
-        u: 0.0,
-        v: 0.0,
-    })
-}
-
-pub fn rectangle() -> Mesh {
-    let mut buffers = VertexBuffers::new();
-
-    buffers.vertices = vec![
-        Vertex {
-            x: 0.0,
-            y: 0.0,
-            u: 0.0,
-            v: 0.0,
-        },
-        Vertex {
-            x: 1.0,
-            y: 0.0,
-            u: 1.0,
-            v: 0.0,
-        },
-        Vertex {
-            x: 1.0,
-            y: 1.0,
-            u: 1.0,
-            v: 1.0,
-        },
-        Vertex {
-            x: 0.0,
-            y: 1.0,
-            u: 0.0,
-            v: 1.0,
-        },
-    ];
-
-    buffers.indices = vec![0, 1, 2, 2, 3, 0];
-
-    Mesh { buffers }
-}
diff --git a/src/rendering/mod.rs b/src/rendering/mod.rs
index 3b783f6..12df1ef 100644
--- a/src/rendering/mod.rs
+++ b/src/rendering/mod.rs
@@ -1,18 +1,20 @@
 //! The rendering module provides a renderer for layout states that is
-//! abstracted over different backends so that it can be used with OpenGL,
-//! DirectX, Vulkan, Metal, WebGL or any other rendering framework. An optional
-//! software renderer is available behind the `software-rendering` feature.
-//! While it is slower than using a proper GPU backend, it might be sufficient
-//! for situations where you want to create a screenshot of the layout.
+//! abstracted over different backends. It focuses on rendering paths, i.e.
+//! lines, quadratic and cubic bezier curves. A backend therefore needs to be
+//! able to render paths. An optional software renderer is available behind the
+//! `software-rendering` feature that uses tiny-skia to efficiently render the
+//! paths on the CPU. It is surprisingly fast and can be considered the default
+//! renderer.
 
 // # Coordinate spaces used in this module
 //
 // ## Backend Coordinate Space
 //
-// The backend has its own coordinate space that ranges from 0 to 1 across both
-// dimensions. (0, 0) is the top left corner of the rendered layout and (1, 1)
-// is the bottom right corner. Since the coordinate space forms a square, the
-// aspect ratio of the layout is not respected.
+// The backend can choose its own coordinate space by passing its own width and
+// height to the renderer. (0, 0) is the top left corner of the rendered layout,
+// and (width, height) is the bottom right corner. In most situations width and
+// height will be the actual pixel dimensions of the image that is being
+// rendered to.
 //
 // ## Renderer Coordinate Space
 //
@@ -50,29 +52,30 @@
 //
 // The default height of a component in the component coordinate space is 1.
 // This is equal to the height of one split or one key value component. The
-// default text size is 0.8. There is a padding of 0.35 to the left and right
+// default text size is 0.725. There is a padding of 0.35 to the left and right
 // side of a component for the contents shown inside a component, such as images
 // and texts. The same padding of 0.35 is also used for the minimum spacing
 // between text and other content such as an icon or another text. A vertical
-// padding of 10% of the height of the available space is chosen unless that is
-// larger than the normal padding. If text doesn't fit, it is to be either
-// abbreviated or overflown via the use of ellipsis. Numbers and times are
-// supposed to be aligned to the right and should be using a monospace text
+// padding of 10% of the height of the available space is chosen for images
+// unless that is larger than the normal padding. If text doesn't fit, it is to
+// be either abbreviated or overflown via the use of ellipsis. Numbers and times
+// are supposed to be aligned to the right and should be using a monospace text
 // layout. Sometimes components are rendered in two row mode. The height of
-// these components is 1.8. All components also need to be able to render with
+// these components is 1.725. All components also need to be able to render with
 // this height in horizontal mode. Separators have a thickness of 0.1, while
 // thin separators have half of this thickness.
 
 mod component;
 mod font;
-mod glyph_cache;
 mod icon;
-mod mesh;
 
 #[cfg(feature = "software-rendering")]
 pub mod software;
 
-use self::{font::Font, glyph_cache::GlyphCache, icon::Icon};
+use self::{
+    font::{Font, GlyphCache, TEXT_FONT, TIMER_FONT},
+    icon::Icon,
+};
 use crate::{
     layout::{ComponentState, LayoutDirection, LayoutState},
     settings::{Color, FontStretch, FontStyle, FontWeight, Gradient},
@@ -82,14 +85,8 @@ use core::iter;
 use euclid::{Transform2D, UnknownUnit};
 use rustybuzz::UnicodeBuffer;
 
-pub use self::mesh::{Mesh, Vertex};
 pub use euclid;
 
-/// The default font to be used for general text. The font is encoded as TTF.
-pub const TEXT_FONT: &[u8] = include_bytes!("fonts/FiraSans-Regular.ttf");
-/// The default font to be used for timers. The font is encoded as TTF.
-pub const TIMER_FONT: &[u8] = include_bytes!("fonts/Timer.ttf");
-
 /// Describes a coordinate in 2D space.
 pub type Pos = [f32; 2];
 /// A color encoded as RGBA (red, green, blue, alpha) where each component is
@@ -103,10 +100,11 @@ const PADDING: f32 = 0.35;
 const BOTH_PADDINGS: f32 = 2.0 * PADDING;
 const BOTH_VERTICAL_PADDINGS: f32 = DEFAULT_COMPONENT_HEIGHT - DEFAULT_TEXT_SIZE;
 const VERTICAL_PADDING: f32 = BOTH_VERTICAL_PADDINGS / 2.0;
+const ICON_MIN_VERTICAL_PADDING: f32 = 0.1;
 const DEFAULT_COMPONENT_HEIGHT: f32 = 1.0;
 const TWO_ROW_HEIGHT: f32 = 2.0 * DEFAULT_TEXT_SIZE + BOTH_VERTICAL_PADDINGS;
-const DEFAULT_TEXT_SIZE: f32 = 0.8;
-const DEFAULT_TEXT_ASCENT: f32 = 0.6;
+const DEFAULT_TEXT_SIZE: f32 = 0.725;
+const DEFAULT_TEXT_ASCENT: f32 = 0.55;
 const DEFAULT_TEXT_DESCENT: f32 = DEFAULT_TEXT_SIZE - DEFAULT_TEXT_ASCENT;
 const TEXT_ALIGN_TOP: f32 = VERTICAL_PADDING + DEFAULT_TEXT_ASCENT;
 const TEXT_ALIGN_BOTTOM: f32 = -(VERTICAL_PADDING + DEFAULT_TEXT_DESCENT);
@@ -117,62 +115,125 @@ const PSEUDO_PIXELS: f32 = 1.0 / 24.0;
 const DEFAULT_VERTICAL_WIDTH: f32 = 11.5;
 
 fn vertical_padding(height: f32) -> f32 {
-    (VERTICAL_PADDING * height).min(PADDING)
+    (ICON_MIN_VERTICAL_PADDING * height).min(PADDING)
+}
+
+/// The backend provides a path builder that defines how to build paths that can
+/// be used for the backend.
+pub trait PathBuilder<B: ?Sized> {
+    /// The type of the path to build. This needs to be identical to the type of
+    /// the path used by the backend.
+    type Path;
+
+    /// Moves the cursor to a specific position and starts a new contour.
+    fn move_to(&mut self, x: f32, y: f32);
+
+    /// Adds a line from the previous position to the position specified, while
+    /// also moving the cursor along.
+    fn line_to(&mut self, x: f32, y: f32);
+
+    /// Adds a quadratic bézier curve from the previous position to the position
+    /// specified, while also moving the cursor along. (x1, y1) specifies the
+    /// control point.
+    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32);
+
+    /// Adds a cubic bézier curve from the previous position to the position
+    /// specified, while also moving the cursor along. (x1, y1) and (x2, y2)
+    /// specify the two control points.
+    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32);
+
+    /// Closes the current contour. The current position and the initial
+    /// position get connected by a line, forming a continuous loop. Nothing
+    /// if the path is empty or already closed.
+    fn close(&mut self);
+
+    /// Finishes building the path.
+    fn finish(self, backend: &mut B) -> Self::Path;
+}
+
+/// Specifies the colors to use when filling a path.
+#[derive(Copy, Clone)]
+pub enum FillShader {
+    /// Use a single color for the whole path.
+    SolidColor(Rgba),
+    /// Use a vertical gradient (top, bottom) to fill the path.
+    VerticalGradient(Rgba, Rgba),
+    /// Use a horizontal gradient (left, right) to fill the path.
+    HorizontalGradient(Rgba, Rgba),
 }
 
 /// The rendering backend for the Renderer is abstracted out into the Backend
 /// trait such that the rendering isn't tied to a specific rendering framework.
 pub trait Backend {
-    /// The type the backend uses for meshes.
-    type Mesh;
+    /// The type the backend uses for building paths.
+    type PathBuilder: PathBuilder<Self, Path = Self::Path>;
+    /// The type the backend uses for paths.
+    type Path;
     /// The type the backend uses for textures.
-    type Texture;
-
-    /// Instructs the backend to create a mesh. The mesh consists out of a
-    /// vertex buffer and an index buffer that describes pairs of three indices
-    /// of the vertex buffer that form a triangle each.
-    fn create_mesh(&mut self, mesh: &Mesh) -> Self::Mesh;
-
-    /// Instructs the backend to render out a mesh. The rendering uses no
-    /// backface culling or depth buffering. The colors are supposed to be alpha
-    /// blended and don't use sRGB. The transform represents a transformation
-    /// matrix to be applied to the mesh's vertices in order to place it in the
-    /// scene. The scene's coordinates are within 0..1 for both x (left..right)
-    /// and y (up..down). There may be a texture that needs to be applied to the
-    /// mesh based on its u and v texture coordinates. There also are four
-    /// colors for are interpolated between based on the u and v texture
-    /// coordinates. The colors are positioned in UV texture space in the
-    /// following way:
-    /// ```rust,ignore
-    /// [
-    ///     (0, 0), // Top Left
-    ///     (1, 0), // Top Right
-    ///     (1, 1), // Bottom Right
-    ///     (0, 1), // Bottom Left
-    /// ]
-    /// ```
-    fn render_mesh(
+    type Image;
+
+    /// Creates a new path builder to build a new path.
+    fn path_builder(&mut self) -> Self::PathBuilder;
+
+    /// Builds a new circle. A default implementation that approximates the
+    /// circle with 4 cubic bézier curves is provided. For more accuracy or
+    /// performance a backend can change the implementation.
+    fn build_circle(&mut self, x: f32, y: f32, r: f32) -> Self::Path {
+        // Based on https://spencermortensen.com/articles/bezier-circle/
+        const C: f64 = 0.551915024494;
+        let c = (C * r as f64) as f32;
+        let mut builder = self.path_builder();
+        builder.move_to(x, y - r);
+        builder.curve_to(x + c, y - r, x + r, y - c, x + r, y);
+        builder.curve_to(x + r, y + c, x + c, y + r, x, y + r);
+        builder.curve_to(x - c, y + r, x - r, y + c, x - r, y);
+        builder.curve_to(x - r, y - c, x - c, y - r, x, y - r);
+        builder.close();
+        builder.finish(self)
+    }
+
+    /// Instructs the backend to render a path with a fill shader. The rendered
+    /// elements are supposed to be drawn on top of each other in the order that
+    /// the backend is being instructed to render them. The colors are supposed
+    /// to be alpha blended and don't use sRGB. The transform represents a
+    /// transformation matrix to be applied to the path in order to place it in
+    /// the scene.
+    fn render_fill_path(&mut self, path: &Self::Path, shader: FillShader, transform: Transform);
+
+    /// Instructs the backend to render a path with a stroke shader. The
+    /// rendered elements are supposed to be drawn on top of each other in the
+    /// order that the backend is being instructed to render them. The colors
+    /// are supposed to be alpha blended and don't use sRGB. The transform
+    /// represents a transformation matrix to be applied to the path in order to
+    /// place it in the scene.
+    fn render_stroke_path(
         &mut self,
-        mesh: &Self::Mesh,
+        path: &Self::Path,
+        stroke_width: f32,
+        color: Rgba,
         transform: Transform,
-        colors: [Rgba; 4],
-        texture: Option<&Self::Texture>,
     );
 
-    /// Instructs the backend to free a mesh as it is not needed anymore.
-    fn free_mesh(&mut self, mesh: Self::Mesh);
-
-    /// Instructs the backend to create a texture out of the texture data
-    /// provided. The texture's resolution is provided as well. The data is an
-    /// array of chunks of RGBA8 encoded pixels (red, green, blue, alpha with
-    /// each channel being an u8).
-    fn create_texture(&mut self, width: u32, height: u32, data: &[u8]) -> Self::Texture;
-
-    /// Instructs the backend to free a texture as it is not needed anymore.
-    fn free_texture(&mut self, texture: Self::Texture);
-
-    /// Instructs the backend to resize the size of the render target.
-    fn resize(&mut self, width: f32, height: f32);
+    /// Instructs the backend to render out an image. An optional rectangle path
+    /// is provided that the backend can use in case it needs a path to render
+    /// out images. The rendered elements are supposed to be drawn on top of
+    /// each other in the order that the backend is being instructed to render
+    /// them. The colors are supposed to be alpha blended and don't use sRGB.
+    /// The transform represents a transformation matrix to be applied to the
+    /// image in order to place it in the scene.
+    fn render_image(&mut self, image: &Self::Image, rectangle: &Self::Path, transform: Transform);
+
+    /// Instructs the backend to free a path as it is not needed anymore.
+    fn free_path(&mut self, path: Self::Path);
+
+    /// Instructs the backend to create an image out of the image data provided.
+    /// The image's resolution is provided as well. The data is an array of
+    /// RGBA8 encoded pixels (red, green, blue, alpha with each channel being an
+    /// u8).
+    fn create_image(&mut self, width: u32, height: u32, data: &[u8]) -> Self::Image;
+
+    /// Instructs the backend to free an image as it is not needed anymore.
+    fn free_image(&mut self, image: Self::Image);
 }
 
 enum CachedSize {
@@ -181,38 +242,38 @@ enum CachedSize {
 }
 
 /// A renderer can be used to render out layout states with the backend chosen.
-pub struct Renderer<M, T> {
+pub struct Renderer<P, I> {
     #[cfg(feature = "font-loading")]
     timer_font_setting: Option<crate::settings::Font>,
     timer_font: Font<'static>,
-    timer_glyph_cache: GlyphCache<M>,
+    timer_glyph_cache: GlyphCache<P>,
     #[cfg(feature = "font-loading")]
     times_font_setting: Option<crate::settings::Font>,
     times_font: Font<'static>,
-    times_glyph_cache: GlyphCache<M>,
+    times_glyph_cache: GlyphCache<P>,
     #[cfg(feature = "font-loading")]
     text_font_setting: Option<crate::settings::Font>,
     text_font: Font<'static>,
-    text_glyph_cache: GlyphCache<M>,
-    rectangle: Option<M>,
+    text_glyph_cache: GlyphCache<P>,
+    rectangle: Option<P>,
     cached_size: Option<CachedSize>,
-    icons: IconCache<T>,
+    icons: IconCache<I>,
     text_buffer: Option<UnicodeBuffer>,
 }
 
-struct IconCache<T> {
-    game_icon: Option<Icon<T>>,
-    split_icons: Vec<Option<Icon<T>>>,
-    detailed_timer_icon: Option<Icon<T>>,
+struct IconCache<I> {
+    game_icon: Option<Icon<I>>,
+    split_icons: Vec<Option<Icon<I>>>,
+    detailed_timer_icon: Option<Icon<I>>,
 }
 
-impl<M, T> Default for Renderer<M, T> {
+impl<P, I> Default for Renderer<P, I> {
     fn default() -> Self {
         Self::new()
     }
 }
 
-impl<M, T> Renderer<M, T> {
+impl<P, I> Renderer<P, I> {
     /// Creates a new renderer.
     pub fn new() -> Self {
         Self {
@@ -263,12 +324,12 @@ impl<M, T> Renderer<M, T> {
     /// Renders the layout state with the backend provided. A resolution needs
     /// to be provided as well so that the contents are rendered according to
     /// aspect ratio of the render target.
-    pub fn render<B: Backend<Mesh = M, Texture = T>>(
+    pub fn render<B: Backend<Path = P, Image = I>>(
         &mut self,
         backend: &mut B,
         resolution: (f32, f32),
         state: &LayoutState,
-    ) {
+    ) -> Option<(f32, f32)> {
         #[cfg(feature = "font-loading")]
         {
             if self.timer_font_setting != state.timer_font {
@@ -335,24 +396,26 @@ impl<M, T> Renderer<M, T> {
         }
     }
 
-    fn render_vertical<B: Backend<Mesh = M, Texture = T>>(
+    fn render_vertical<B: Backend<Path = P, Image = I>>(
         &mut self,
         backend: &mut B,
         resolution: (f32, f32),
         state: &LayoutState,
-    ) {
+    ) -> Option<(f32, f32)> {
         let total_height = state.components.iter().map(component_height).sum::<f32>();
 
         let cached_total_size = self
             .cached_size
             .get_or_insert(CachedSize::Vertical(total_height));
+        let mut new_resolution = None;
+
         match cached_total_size {
             CachedSize::Vertical(cached_total_height) => {
-                if *cached_total_height != total_height {
-                    backend.resize(
+                if cached_total_height.to_ne_bytes() != total_height.to_ne_bytes() {
+                    new_resolution = Some((
                         resolution.0,
                         resolution.1 / *cached_total_height * total_height,
-                    );
+                    ));
                     *cached_total_height = total_height;
                 }
             }
@@ -360,7 +423,7 @@ impl<M, T> Renderer<M, T> {
                 let to_pixels = resolution.1 / TWO_ROW_HEIGHT;
                 let new_height = total_height * to_pixels;
                 let new_width = DEFAULT_VERTICAL_WIDTH * to_pixels;
-                backend.resize(new_width, new_height);
+                new_resolution = Some((new_width, new_height));
                 *cached_total_size = CachedSize::Vertical(total_height);
             }
         }
@@ -369,7 +432,7 @@ impl<M, T> Renderer<M, T> {
 
         let mut context = RenderContext {
             backend,
-            transform: Transform::identity(),
+            transform: Transform::scale(resolution.0 as f32, resolution.1 as f32),
             rectangle: &mut self.rectangle,
             timer_font: &self.timer_font,
             timer_glyph_cache: &mut self.timer_glyph_cache,
@@ -408,32 +471,36 @@ impl<M, T> Renderer<M, T> {
             // current component in the Component Coordinate Space.
             context.translate(0.0, height);
         }
+
+        new_resolution
     }
 
-    fn render_horizontal<B: Backend<Mesh = M, Texture = T>>(
+    fn render_horizontal<B: Backend<Path = P, Image = I>>(
         &mut self,
         backend: &mut B,
         resolution: (f32, f32),
         state: &LayoutState,
-    ) {
+    ) -> Option<(f32, f32)> {
         let total_width = state.components.iter().map(component_width).sum::<f32>();
 
         let cached_total_size = self
             .cached_size
             .get_or_insert(CachedSize::Horizontal(total_width));
+        let mut new_resolution = None;
+
         match cached_total_size {
             CachedSize::Vertical(cached_total_height) => {
                 let new_height = resolution.1 * TWO_ROW_HEIGHT / *cached_total_height;
                 let new_width = total_width * new_height / TWO_ROW_HEIGHT;
-                backend.resize(new_width, new_height);
+                new_resolution = Some((new_width, new_height));
                 *cached_total_size = CachedSize::Horizontal(total_width);
             }
             CachedSize::Horizontal(cached_total_width) => {
-                if *cached_total_width != total_width {
-                    backend.resize(
+                if cached_total_width.to_ne_bytes() != total_width.to_ne_bytes() {
+                    new_resolution = Some((
                         resolution.0 / *cached_total_width * total_width,
                         resolution.1,
-                    );
+                    ));
                     *cached_total_width = total_width;
                 }
             }
@@ -443,7 +510,7 @@ impl<M, T> Renderer<M, T> {
 
         let mut context = RenderContext {
             backend,
-            transform: Transform::identity(),
+            transform: Transform::scale(resolution.0 as f32, resolution.1 as f32),
             rectangle: &mut self.rectangle,
             timer_font: &mut self.timer_font,
             timer_glyph_cache: &mut self.timer_glyph_cache,
@@ -486,12 +553,14 @@ impl<M, T> Renderer<M, T> {
             // current component in the Component Coordinate Space.
             context.translate(width, 0.0);
         }
+
+        new_resolution
     }
 }
 
 fn render_component<B: Backend>(
     context: &mut RenderContext<'_, B>,
-    icons: &mut IconCache<B::Texture>,
+    icons: &mut IconCache<B::Image>,
     component: &ComponentState,
     state: &LayoutState,
     dim: [f32; 2],
@@ -530,58 +599,67 @@ fn render_component<B: Backend>(
 struct RenderContext<'b, B: Backend> {
     transform: Transform,
     backend: &'b mut B,
-    rectangle: &'b mut Option<B::Mesh>,
+    rectangle: &'b mut Option<B::Path>,
     timer_font: &'b Font<'static>,
-    timer_glyph_cache: &'b mut GlyphCache<B::Mesh>,
+    timer_glyph_cache: &'b mut GlyphCache<B::Path>,
     text_font: &'b Font<'static>,
-    text_glyph_cache: &'b mut GlyphCache<B::Mesh>,
+    text_glyph_cache: &'b mut GlyphCache<B::Path>,
     times_font: &'b Font<'static>,
-    times_glyph_cache: &'b mut GlyphCache<B::Mesh>,
+    times_glyph_cache: &'b mut GlyphCache<B::Path>,
     text_buffer: &'b mut Option<UnicodeBuffer>,
 }
 
 impl<B: Backend> RenderContext<'_, B> {
-    fn backend_render_rectangle(&mut self, [x1, y1]: Pos, [x2, y2]: Pos, colors: [Rgba; 4]) {
+    fn backend_render_rectangle(&mut self, [x1, y1]: Pos, [x2, y2]: Pos, shader: FillShader) {
         let transform = self
             .transform
             .pre_translate([x1, y1].into())
             .pre_scale(x2 - x1, y2 - y1);
 
         let rectangle = self.rectangle.get_or_insert_with({
-            let backend = &mut self.backend;
-            move || backend.create_mesh(&mesh::rectangle())
+            let backend = &mut *self.backend;
+            move || {
+                let mut builder = backend.path_builder();
+                builder.move_to(0.0, 0.0);
+                builder.line_to(0.0, 1.0);
+                builder.line_to(1.0, 1.0);
+                builder.line_to(1.0, 0.0);
+                builder.close();
+                builder.finish(backend)
+            }
         });
 
-        self.backend.render_mesh(rectangle, transform, colors, None);
+        self.backend.render_fill_path(rectangle, shader, transform);
     }
 
-    fn create_mesh(&mut self, mesh: &Mesh) -> B::Mesh {
-        self.backend.create_mesh(mesh)
+    fn render_path(&mut self, path: &B::Path, color: Color) {
+        self.backend
+            .render_fill_path(path, solid(&color), self.transform)
     }
 
-    fn render_mesh(&mut self, mesh: &B::Mesh, color: Color) {
+    fn render_stroke_path(&mut self, path: &B::Path, color: Color, stroke_width: f32) {
         self.backend
-            .render_mesh(mesh, self.transform, [decode_color(&color); 4], None)
+            .render_stroke_path(path, stroke_width, decode_color(&color), self.transform)
     }
 
-    fn create_icon(&mut self, image_data: &[u8]) -> Option<Icon<B::Texture>> {
+    fn create_icon(&mut self, image_data: &[u8]) -> Option<Icon<B::Image>> {
         if image_data.is_empty() {
             return None;
         }
 
         let image = image::load_from_memory(image_data).ok()?.to_rgba8();
-        let texture = self
+        let backend_image = self
             .backend
-            .create_texture(image.width(), image.height(), &image);
+            .create_image(image.width(), image.height(), &image);
 
         Some(Icon {
-            texture,
+            image: backend_image,
             aspect_ratio: image.width() as f32 / image.height() as f32,
         })
     }
 
-    fn free_mesh(&mut self, mesh: B::Mesh) {
-        self.backend.free_mesh(mesh)
+    fn free_path(&mut self, path: B::Path) {
+        self.backend.free_path(path)
     }
 
     fn scale(&mut self, factor: f32) {
@@ -606,7 +684,7 @@ impl<B: Backend> RenderContext<'_, B> {
         &mut self,
         [mut x, mut y]: Pos,
         [mut width, mut height]: Pos,
-        icon: &Icon<B::Texture>,
+        icon: &Icon<B::Image>,
     ) {
         let box_aspect_ratio = width / height;
         let aspect_ratio_diff = box_aspect_ratio / icon.aspect_ratio;
@@ -628,13 +706,21 @@ impl<B: Backend> RenderContext<'_, B> {
             .pre_translate([x, y].into())
             .pre_scale(width, height);
 
+        // FIXME: Deduplicate
         let rectangle = self.rectangle.get_or_insert_with({
-            let backend = &mut self.backend;
-            move || backend.create_mesh(&mesh::rectangle())
+            let backend = &mut *self.backend;
+            move || {
+                let mut builder = backend.path_builder();
+                builder.move_to(0.0, 0.0);
+                builder.line_to(0.0, 1.0);
+                builder.line_to(1.0, 1.0);
+                builder.line_to(1.0, 0.0);
+                builder.close();
+                builder.finish(backend)
+            }
         });
 
-        self.backend
-            .render_mesh(rectangle, transform, [[1.0; 4]; 4], Some(&icon.texture));
+        self.backend.render_image(&icon.image, rectangle, transform);
     }
 
     fn render_background(&mut self, background: &Gradient) {
@@ -655,7 +741,7 @@ impl<B: Backend> RenderContext<'_, B> {
             value,
             [width - PADDING, height + TEXT_ALIGN_BOTTOM],
             DEFAULT_TEXT_SIZE,
-            [value_color; 2],
+            solid(&value_color),
         );
         let end_x = if display_two_rows {
             width
@@ -671,7 +757,7 @@ impl<B: Backend> RenderContext<'_, B> {
             key,
             [PADDING, TEXT_ALIGN_TOP],
             DEFAULT_TEXT_SIZE,
-            [key_color; 2],
+            solid(&key_color),
             end_x - PADDING,
         );
     }
@@ -681,7 +767,7 @@ impl<B: Backend> RenderContext<'_, B> {
         text: &str,
         pos: Pos,
         scale: f32,
-        colors: [Color; 2],
+        shader: FillShader,
         max_x: f32,
     ) -> f32 {
         let mut cursor = font::Cursor::new(pos);
@@ -695,7 +781,7 @@ impl<B: Backend> RenderContext<'_, B> {
 
         font::render(
             glyphs.left_aligned(&mut cursor, max_x),
-            colors,
+            shader,
             &font,
             self.text_glyph_cache,
             &self.transform,
@@ -714,7 +800,7 @@ impl<B: Backend> RenderContext<'_, B> {
         max_x: f32,
         pos: Pos,
         scale: f32,
-        color: Color,
+        shader: FillShader,
     ) {
         let mut cursor = font::Cursor::new(pos);
 
@@ -727,7 +813,7 @@ impl<B: Backend> RenderContext<'_, B> {
 
         font::render(
             glyphs.centered(&mut cursor, min_x, max_x),
-            [color; 2],
+            shader,
             &font,
             self.text_glyph_cache,
             &self.transform,
@@ -742,7 +828,7 @@ impl<B: Backend> RenderContext<'_, B> {
         text: &str,
         pos: Pos,
         scale: f32,
-        colors: [Color; 2],
+        shader: FillShader,
     ) -> f32 {
         let mut cursor = font::Cursor::new(pos);
 
@@ -755,7 +841,7 @@ impl<B: Backend> RenderContext<'_, B> {
 
         font::render(
             glyphs.right_aligned(&mut cursor),
-            colors,
+            shader,
             &font,
             self.text_glyph_cache,
             &self.transform,
@@ -775,16 +861,16 @@ impl<B: Backend> RenderContext<'_, B> {
         pos: Pos,
         scale: f32,
         centered: bool,
-        color: Color,
+        shader: FillShader,
     ) {
         if centered {
-            self.render_text_centered(text, min_x, max_x, pos, scale, color);
+            self.render_text_centered(text, min_x, max_x, pos, scale, shader);
         } else {
-            self.render_text_ellipsis(text, pos, scale, [color; 2], max_x);
+            self.render_text_ellipsis(text, pos, scale, shader, max_x);
         }
     }
 
-    fn render_numbers(&mut self, text: &str, pos: Pos, scale: f32, colors: [Color; 2]) -> f32 {
+    fn render_numbers(&mut self, text: &str, pos: Pos, scale: f32, shader: FillShader) -> f32 {
         let mut cursor = font::Cursor::new(pos);
 
         let mut buffer = self.text_buffer.take().unwrap_or_default();
@@ -796,7 +882,7 @@ impl<B: Backend> RenderContext<'_, B> {
 
         font::render(
             glyphs.tabular_numbers(&mut cursor),
-            colors,
+            shader,
             &font,
             self.times_glyph_cache,
             &self.transform,
@@ -808,7 +894,7 @@ impl<B: Backend> RenderContext<'_, B> {
         cursor.x
     }
 
-    fn render_timer(&mut self, text: &str, pos: Pos, scale: f32, colors: [Color; 2]) -> f32 {
+    fn render_timer(&mut self, text: &str, pos: Pos, scale: f32, shader: FillShader) -> f32 {
         let mut cursor = font::Cursor::new(pos);
 
         let mut buffer = self.text_buffer.take().unwrap_or_default();
@@ -820,7 +906,7 @@ impl<B: Backend> RenderContext<'_, B> {
 
         font::render(
             glyphs.tabular_numbers(&mut cursor),
-            colors,
+            shader,
             &font,
             self.timer_glyph_cache,
             &self.transform,
@@ -901,22 +987,22 @@ impl<B: Backend> RenderContext<'_, B> {
     }
 }
 
-fn decode_gradient(gradient: &Gradient) -> Option<[[f32; 4]; 4]> {
+fn decode_gradient(gradient: &Gradient) -> Option<FillShader> {
     Some(match gradient {
         Gradient::Transparent => return None,
         Gradient::Horizontal(left, right) => {
             let left = decode_color(left);
             let right = decode_color(right);
-            [left, right, right, left]
+            FillShader::HorizontalGradient(left, right)
         }
         Gradient::Vertical(top, bottom) => {
             let top = decode_color(top);
             let bottom = decode_color(bottom);
-            [top, top, bottom, bottom]
+            FillShader::VerticalGradient(top, bottom)
         }
         Gradient::Plain(plain) => {
             let plain = decode_color(plain);
-            [plain; 4]
+            FillShader::SolidColor(plain)
         }
     })
 }
@@ -926,6 +1012,10 @@ fn decode_color(color: &Color) -> [f32; 4] {
     [r, g, b, a]
 }
 
+fn solid(color: &Color) -> FillShader {
+    FillShader::SolidColor(decode_color(color))
+}
+
 fn component_width(component: &ComponentState) -> f32 {
     match component {
         ComponentState::BlankSpace(state) => state.size as f32 * PSEUDO_PIXELS,
diff --git a/src/rendering/software.rs b/src/rendering/software.rs
index c6733ae..5a4e0fd 100644
--- a/src/rendering/software.rs
+++ b/src/rendering/software.rs
@@ -1,238 +1,315 @@
-//! Provides a software renderer that can be used without a GPU. The rendering
-//! is much slower than with a normal GPU, but might be sufficient for
-//! situations where you want to create a screenshot of the layout.
-
-use {
-    crate::{
-        layout::LayoutState,
-        rendering::{Backend, Mesh, Renderer, Rgba as LSColor, Transform},
-    },
-    derive_more::{Add, Mul},
-    euc::{
-        buffer::Buffer2d,
-        rasterizer::{self, BackfaceCullingDisabled, DepthStrategy},
-        Interpolate, Pipeline, Target,
-    },
-    vek::{Mat3, Rgba, Vec2, Vec3},
+//! Provides a software renderer that can be used without a GPU. The renderer is
+//! surprisingly fast and can be considered the default rendering backend.
+
+use super::{Backend, FillShader, Renderer, Rgba, Transform};
+use crate::layout::LayoutState;
+use image::ImageBuffer;
+use tiny_skia::{
+    Canvas, Color, FillRule, FilterQuality, GradientStop, LinearGradient, Paint, Path, PathBuilder,
+    Pattern, Pixmap, PixmapMut, Point, Shader, SpreadMode, Stroke,
 };
 
 pub use image::{self, RgbaImage};
 
-struct SoftwareBackend {
-    color: AlphaBlended,
-}
+struct SkiaBuilder(PathBuilder);
 
-impl Backend for SoftwareBackend {
-    type Mesh = Vec<Vertex>;
-    type Texture = Texture;
-
-    fn create_mesh(&mut self, mesh: &Mesh) -> Self::Mesh {
-        let vertices = mesh.vertices();
-        mesh.indices()
-            .iter()
-            .map(|&index| {
-                let v = vertices[index as usize];
-                Vertex {
-                    position: Vec2::new(v.x, v.y),
-                    texcoord: Vec2::new(v.u, v.v),
-                }
-            })
-            .collect()
+impl super::PathBuilder<SoftwareBackend<'_>> for SkiaBuilder {
+    type Path = Option<Path>;
+
+    fn move_to(&mut self, x: f32, y: f32) {
+        self.0.move_to(x, y)
     }
 
-    fn render_mesh(
-        &mut self,
-        mesh: &Self::Mesh,
-        transform: Transform,
-        [tl, tr, br, bl]: [LSColor; 4],
-        texture: Option<&Self::Texture>,
-    ) {
-        let [x1, y1, z1, x2, y2, z2] = transform.to_column_major_array();
-        MyPipeline {
-            transform: Mat3::new(x1, x2, 0.0, y1, y2, 0.0, z1, z2, 0.0),
-            color_tl: Rgba::new(tl[0], tl[1], tl[2], tl[3]),
-            color_tr: Rgba::new(tr[0], tr[1], tr[2], tr[3]),
-            color_bl: Rgba::new(bl[0], bl[1], bl[2], bl[3]),
-            color_br: Rgba::new(br[0], br[1], br[2], br[3]),
-            texture,
-        }
-        .draw::<rasterizer::Triangles<'_, (f32,), BackfaceCullingDisabled>, _>(
-            mesh,
-            &mut self.color,
-            None,
-        );
+    fn line_to(&mut self, x: f32, y: f32) {
+        self.0.line_to(x, y)
     }
 
-    fn free_mesh(&mut self, _: Self::Mesh) {}
+    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
+        self.0.quad_to(x1, y1, x, y)
+    }
 
-    fn create_texture(&mut self, width: u32, height: u32, data: &[u8]) -> Self::Texture {
-        Texture {
-            data: data.to_owned(),
-            width: width as f32,
-            height: height as f32,
-            stride: width as usize * 4,
-        }
+    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
+        self.0.cubic_to(x1, y1, x2, y2, x, y)
+    }
+
+    fn close(&mut self) {
+        self.0.close()
     }
-    fn free_texture(&mut self, _: Self::Texture) {}
 
-    fn resize(&mut self, _: f32, _: f32) {}
+    fn finish(self, _: &mut SoftwareBackend<'_>) -> Self::Path {
+        self.0.finish()
+    }
 }
 
-struct AlphaBlended(Buffer2d<Rgba<f32>>);
+fn convert_color([r, g, b, a]: [f32; 4]) -> Color {
+    Color::from_rgba(r, g, b, a).unwrap()
+}
 
-impl Target for AlphaBlended {
-    type Item = Rgba<f32>;
+fn convert_transform(transform: Transform) -> tiny_skia::Transform {
+    let [sx, ky, kx, sy, tx, ty] = transform.to_array();
+    tiny_skia::Transform::from_row(sx, ky, kx, sy, tx, ty).unwrap()
+}
+
+struct SoftwareBackend<'a> {
+    canvas: Canvas<'a>,
+}
 
-    fn size(&self) -> [usize; 2] {
-        self.0.size()
+impl Backend for SoftwareBackend<'_> {
+    type PathBuilder = SkiaBuilder;
+    type Path = Option<Path>;
+    type Image = Option<Pixmap>;
+
+    fn path_builder(&mut self) -> Self::PathBuilder {
+        SkiaBuilder(PathBuilder::new())
     }
 
-    unsafe fn set(&mut self, pos: [usize; 2], src: Self::Item) {
-        debug_assert!(!src.r.is_nan());
-        debug_assert!(!src.g.is_nan());
-        debug_assert!(!src.b.is_nan());
-        debug_assert!(!src.a.is_nan());
+    fn render_fill_path(&mut self, path: &Self::Path, shader: FillShader, transform: Transform) {
+        if let Some(path) = path {
+            self.canvas.set_transform(convert_transform(transform));
+
+            let shader = match shader {
+                FillShader::SolidColor(col) => Shader::SolidColor(convert_color(col)),
+                FillShader::VerticalGradient(top, bottom) => {
+                    let bounds = path.bounds();
+                    LinearGradient::new(
+                        Point::from_xy(0.0, bounds.top()),
+                        Point::from_xy(0.0, bounds.bottom()),
+                        vec![
+                            GradientStop::new(0.0, convert_color(top)),
+                            GradientStop::new(1.0, convert_color(bottom)),
+                        ],
+                        SpreadMode::Pad,
+                        tiny_skia::Transform::identity(),
+                    )
+                    .unwrap()
+                }
+                FillShader::HorizontalGradient(left, right) => {
+                    let bounds = path.bounds();
+                    LinearGradient::new(
+                        Point::from_xy(bounds.left(), 0.0),
+                        Point::from_xy(bounds.right(), 0.0),
+                        vec![
+                            GradientStop::new(0.0, convert_color(left)),
+                            GradientStop::new(1.0, convert_color(right)),
+                        ],
+                        SpreadMode::Pad,
+                        tiny_skia::Transform::identity(),
+                    )
+                    .unwrap()
+                }
+            };
+
+            self.canvas.fill_path(
+                path,
+                &Paint {
+                    shader,
+                    anti_alias: true,
+                    ..Default::default()
+                },
+                FillRule::Winding,
+            );
+        }
+    }
 
-        let dst = self.0.get(pos);
-        self.0.set(
-            pos,
-            Rgba::new(
-                src.a * src.r + (1.0 - src.a) * dst.r,
-                src.a * src.g + (1.0 - src.a) * dst.g,
-                src.a * src.b + (1.0 - src.a) * dst.b,
-                src.a + (1.0 - src.a) * dst.a,
-            ),
-        );
+    fn render_stroke_path(
+        &mut self,
+        path: &Self::Path,
+        stroke_width: f32,
+        color: Rgba,
+        transform: Transform,
+    ) {
+        if let Some(path) = path {
+            self.canvas.set_transform(convert_transform(transform));
+
+            self.canvas.stroke_path(
+                path,
+                &Paint {
+                    shader: Shader::SolidColor(convert_color(color)),
+                    anti_alias: true,
+                    ..Default::default()
+                },
+                &Stroke {
+                    width: stroke_width,
+                    ..Default::default()
+                },
+            );
+        }
     }
 
-    unsafe fn get(&self, pos: [usize; 2]) -> Self::Item {
-        self.0.get(pos)
+    fn render_image(&mut self, image: &Self::Image, rectangle: &Self::Path, transform: Transform) {
+        if let (Some(path), Some(image)) = (rectangle, image) {
+            self.canvas.set_transform(convert_transform(transform));
+
+            self.canvas.fill_path(
+                path,
+                &Paint {
+                    shader: Pattern::new(
+                        image.as_ref(),
+                        SpreadMode::Pad,
+                        FilterQuality::Bilinear,
+                        1.0,
+                        tiny_skia::Transform::from_scale(
+                            1.0 / image.width() as f32,
+                            1.0 / image.height() as f32,
+                        )
+                        .unwrap(),
+                    ),
+                    anti_alias: true,
+                    ..Default::default()
+                },
+                FillRule::Winding,
+            );
+        }
     }
 
-    fn clear(&mut self, fill: Self::Item) {
-        self.0.clear(fill)
+    fn free_path(&mut self, _: Self::Path) {}
+
+    fn create_image(&mut self, width: u32, height: u32, data: &[u8]) -> Self::Image {
+        let mut image = Pixmap::new(width, height)?;
+        for (d, &[r, g, b, a]) in image
+            .pixels_mut()
+            .iter_mut()
+            .zip(bytemuck::cast_slice::<_, [u8; 4]>(data))
+        {
+            *d = tiny_skia::ColorU8::from_rgba(r, g, b, a).premultiply();
+        }
+        Some(image)
     }
+
+    fn free_image(&mut self, _: Self::Image) {}
 }
 
-struct Texture {
-    data: Vec<u8>,
-    width: f32,
-    height: f32,
-    stride: usize,
+/// The software renderer allows rendering layouts entirely on the CPU. This is
+/// surprisingly fast and can be considered the default renderer. There are two
+/// versions of the software renderer. This version of the software renderer
+/// does not own the image to render into. This allows the caller to manage
+/// their own image buffer.
+pub struct BorrowedSoftwareRenderer {
+    renderer: Renderer<Option<Path>, Option<Pixmap>>,
 }
 
-struct MyPipeline<'a> {
-    transform: Mat3<f32>,
-    color_tl: Rgba<f32>,
-    color_tr: Rgba<f32>,
-    color_bl: Rgba<f32>,
-    color_br: Rgba<f32>,
-    texture: Option<&'a Texture>,
+impl Default for BorrowedSoftwareRenderer {
+    fn default() -> Self {
+        Self::new()
+    }
 }
 
-struct Vertex {
-    position: Vec2<f32>,
-    texcoord: Vec2<f32>,
+impl BorrowedSoftwareRenderer {
+    /// Creates a new software renderer.
+    pub fn new() -> Self {
+        Self {
+            renderer: Renderer::new(),
+        }
+    }
+
+    /// Renders the layout state provided into the image buffer provided. The
+    /// image has to be an array of RGBA8 encoded pixels (red, green, blue,
+    /// alpha with each channel being an u8). Some frameworks may over allocate
+    /// an image's dimensions. So an image with dimensions 100x50 may be over
+    /// allocated as 128x64. In that case you provide the real dimensions of
+    /// [100, 50] as the width and height, but a stride of 128 pixels as that
+    /// correlates with the real width of the underlying buffer. It may detect
+    /// that the layout got resized. In that case it returns the new ideal size.
+    /// This is entirely a hint and can be ignored entirely. The image is always
+    /// rendered with the resolution provided.
+    pub fn render(
+        &mut self,
+        state: &LayoutState,
+        image: &mut [u8],
+        [width, height]: [u32; 2],
+        stride: u32,
+    ) -> Option<(f32, f32)> {
+        let mut pixmap = PixmapMut::from_bytes(image, stride, height).unwrap();
+
+        // FIXME: .fill() once it's stable.
+        for b in pixmap.data_mut() {
+            *b = 0;
+        }
+
+        let mut backend = SoftwareBackend {
+            canvas: Canvas::from(pixmap),
+        };
+
+        self.renderer
+            .render(&mut backend, (width as _, height as _), &state)
+    }
 }
 
-#[derive(Clone, Mul, Add)]
-struct VsOut {
-    color: Rgba<f32>,
-    texcoord: Vec2<f32>,
+/// The software renderer allows rendering layouts entirely on the CPU. This is
+/// surprisingly fast and can be considered the default renderer. There are two
+/// versions of the software renderer. This version of the software renderer
+/// owns the image it renders into.
+pub struct SoftwareRenderer {
+    renderer: Renderer<Option<Path>, Option<Pixmap>>,
+    pixmap: Pixmap,
 }
 
-impl Interpolate for VsOut {
-    #[inline(always)]
-    fn lerp2(fx: Self, fy: Self, x: f32, y: f32) -> Self {
-        fx * x + fy * y
-    }
-    #[inline(always)]
-    fn lerp3(fx: Self, fy: Self, fz: Self, x: f32, y: f32, z: f32) -> Self {
-        fx * x + fy * y + fz * z
+impl Default for SoftwareRenderer {
+    fn default() -> Self {
+        Self::new()
     }
 }
 
-impl Pipeline for MyPipeline<'_> {
-    type Vertex = Vertex;
-    type VsOut = VsOut;
-    type Pixel = Rgba<f32>;
+impl SoftwareRenderer {
+    /// Creates a new software renderer.
+    pub fn new() -> Self {
+        Self {
+            renderer: Renderer::new(),
+            pixmap: Pixmap::new(1, 1).unwrap(),
+        }
+    }
 
-    fn vert(&self, vertex: &Self::Vertex) -> ([f32; 4], Self::VsOut) {
-        let left = self.color_tl * (1.0 - vertex.texcoord.y) + self.color_bl * vertex.texcoord.y;
-        let right = self.color_tr * (1.0 - vertex.texcoord.y) + self.color_br * vertex.texcoord.y;
-        let color = left * (1.0 - vertex.texcoord.x) + right * vertex.texcoord.x;
+    /// Renders the layout state provided with the chosen resolution. It may
+    /// detect that the layout got resized. In that case it returns the new
+    /// ideal size. This is entirely a hint and can be ignored entirely. The
+    /// image is always rendered with the resolution provided.
+    pub fn render(&mut self, state: &LayoutState, [width, height]: [u32; 2]) -> Option<(f32, f32)> {
+        if width != self.pixmap.width() || height != self.pixmap.height() {
+            self.pixmap = Pixmap::new(width, height).unwrap();
+        } else {
+            // FIXME: .fill() once it's stable.
+            for b in self.pixmap.data_mut() {
+                *b = 0;
+            }
+        }
 
-        let pos = Vec3::new(vertex.position.x, vertex.position.y, 1.0) * self.transform;
+        let mut backend = SoftwareBackend {
+            canvas: Canvas::from(self.pixmap.as_mut()),
+        };
 
-        (
-            [2.0 * pos.x - 1.0, -2.0 * pos.y + 1.0, 0.0, 1.0],
-            VsOut {
-                color,
-                texcoord: vertex.texcoord,
-            },
-        )
+        self.renderer
+            .render(&mut backend, (width as _, height as _), &state)
     }
 
-    fn frag(&self, vsout: &Self::VsOut) -> Self::Pixel {
-        if let Some(texture) = self.texture {
-            let x = vsout.texcoord.x * texture.width;
-            let y = vsout.texcoord.y * texture.height;
-            let pixel = &texture.data[texture.stride * y as usize + x as usize * 4..];
-            return Rgba::new(
-                f32::from(pixel[0]) / 255.0,
-                f32::from(pixel[1]) / 255.0,
-                f32::from(pixel[2]) / 255.0,
-                f32::from(pixel[3]) / 255.0,
-            ) * vsout.color;
-        }
-        vsout.color
+    /// Accesses the image as a byte slice of RGBA8 encoded pixels (red, green,
+    /// blue, alpha with each channel being an u8).
+    pub fn image_data(&self) -> &[u8] {
+        self.pixmap.data()
     }
 
-    fn get_depth_strategy(&self) -> DepthStrategy {
-        DepthStrategy::None
+    /// Turns the whole renderer into the underlying image buffer of RGBA8
+    /// encoded pixels (red, green, blue, alpha with each channel being an u8).
+    pub fn into_image_data(self) -> Vec<u8> {
+        self.pixmap.take()
     }
-}
 
-/// Renders the layout state provided into an image of the selected resolution.
-/// The final render will have pixelated edges as there is not going to be any
-/// anti aliasing. Use [`render_anti_aliased`] if you want it to be anti
-/// aliased. Note that this is software rendered and thus will be much slower
-/// than rendering on the GPU.
-///
-/// [`render_anti_aliased`]: fn.render_anti_aliased.html
-pub fn render(state: &LayoutState, [width, height]: [usize; 2]) -> RgbaImage {
-    let mut backend = SoftwareBackend {
-        color: AlphaBlended(Buffer2d::new(
-            [width, height],
-            Rgba::new(0.0, 0.0, 0.0, 0.0),
-        )),
-    };
-
-    Renderer::new().render(&mut backend, (width as _, height as _), &state);
-
-    let mut buf = Vec::with_capacity(width * height * 4);
-    for pixel in backend.color.0.as_ref() {
-        buf.extend(
-            pixel
-                .map(|e| (e * 255.0) as u8)
-                .into_array()
-                .iter()
-                .cloned(),
+    /// Accesses the image.
+    pub fn image(&self) -> ImageBuffer<image::Rgba<u8>, &[u8]> {
+        ImageBuffer::from_raw(
+            self.pixmap.width(),
+            self.pixmap.height(),
+            self.pixmap.data(),
         )
+        .unwrap()
     }
 
-    RgbaImage::from_raw(width as _, height as _, buf).unwrap()
-}
-
-/// Renders the layout state provided into an image of the selected resolution.
-/// The `samples_sqrt` argument is the square root of the amount of samples used
-/// for anti aliasing the final image. Note that this is software rendered and
-/// thus will be much slower than rendering on the GPU.
-pub fn render_anti_aliased(
-    state: &LayoutState,
-    [width, height]: [usize; 2],
-    samples_sqrt: usize,
-) -> RgbaImage {
-    let image = render(state, [width * samples_sqrt, height * samples_sqrt]);
-    image::imageops::thumbnail(&image, width as u32, height as u32)
+    /// Turns the whole renderer into the underlying image.
+    pub fn into_image(self) -> RgbaImage {
+        RgbaImage::from_raw(
+            self.pixmap.width(),
+            self.pixmap.height(),
+            self.pixmap.take(),
+        )
+        .unwrap()
+    }
 }
diff --git a/src/run/comparisons.rs b/src/run/comparisons.rs
index 0b9b505..ac7e7a1 100644
--- a/src/run/comparisons.rs
+++ b/src/run/comparisons.rs
@@ -1,5 +1,4 @@
-use crate::platform::prelude::*;
-use crate::Time;
+use crate::{platform::prelude::*, Time};
 
 // We use a Vec here because a HashMap would require hashing the comparison and
 // then comparing the comparison with the string at the index calculated from
diff --git a/src/xml_util.rs b/src/xml_util.rs
index 1115f6e..dd4d4d9 100644
--- a/src/xml_util.rs
+++ b/src/xml_util.rs
@@ -1,12 +1,15 @@
 use crate::timing;
-use alloc::borrow::Cow;
-use alloc::string;
+use alloc::{borrow::Cow, string};
 use chrono::ParseError as ChronoError;
-use core::num::{ParseFloatError, ParseIntError};
-use core::ops::Deref;
-use core::str;
-use quick_xml::events::{attributes, BytesStart, Event};
-use quick_xml::{Error as XmlError, Reader, Writer};
+use core::{
+    num::{ParseFloatError, ParseIntError},
+    ops::Deref,
+    str,
+};
+use quick_xml::{
+    events::{attributes, BytesStart, Event},
+    Error as XmlError, Reader, Writer,
+};
 use std::io::{self, BufRead};
 
 /// The Error type for XML-based splits files that couldn't be parsed.


Test patch:
diff --git a/tests/rendering.rs b/tests/rendering.rs
index 6b31f65..cde0190 100644
--- a/tests/rendering.rs
+++ b/tests/rendering.rs
@@ -8,9 +8,9 @@ mod tests_helper;
 use image::Rgba;
 use img_hash::{HasherConfig, ImageHash};
 use livesplit_core::{
-    component,
-    layout::{self, Component, Layout, LayoutDirection, LayoutState},
-    rendering::software::render,
+    component::{self, timer},
+    layout::{self, Component, ComponentState, Layout, LayoutDirection, LayoutState},
+    rendering::software::SoftwareRenderer,
     run::parser::{livesplit, llanfair, wsplit},
     Run, Segment, TimeSpan, Timer, TimingMethod,
 };
@@ -42,20 +42,18 @@ fn default() {
 
     let state = layout.state(&timer.snapshot());
 
-    check(&state, "luCKVRJIOLM=", "default");
+    check(&state, "luoAAABANDM=", "default");
 }
 
 #[test]
 fn actual_split_file() {
-    // FIXME: What are we doing about this in regard to crater? These should
-    // likely be in the tests folder.
     let run = lss(run_files::LIVESPLIT_1_0);
     let timer = Timer::new(run).unwrap();
     let mut layout = Layout::default_layout();
 
     check(
         &layout.state(&timer.snapshot()),
-        "jsTEKxBAPLc=",
+        "jMTAARBAPDM=",
         "actual_split_file",
     );
 }
@@ -69,7 +67,7 @@ fn wsplit() {
     check_dims(
         &layout.state(&timer.snapshot()),
         [250, 300],
-        "jvHd3fnZPuc=",
+        "j/n83PnZv/c=",
         "wsplit",
     );
 }
@@ -87,15 +85,13 @@ fn all_components() {
 
     let state = layout.state(&timer.snapshot());
 
-    check_dims(&state, [300, 800], "4en3sdHBp/E=", "all_components");
+    check_dims(&state, [300, 800], "4en3ocnJJ/E=", "all_components");
 
-    check_dims(&state, [150, 800], "TXfHZWVJRmc=", "all_components_thin");
+    check_dims(&state, [150, 800], "SXfPSWVpRlc=", "all_components_thin");
 }
 
 #[test]
 fn score_split() {
-    use crate::{component::timer, layout::ComponentState};
-
     let run = lss(run_files::LIVESPLIT_1_0);
     let timer = Timer::new(run).unwrap();
     let mut layout = Layout::default_layout();
@@ -110,7 +106,7 @@ fn score_split() {
     state.components.push(ComponentState::Timer(timer_state));
     state.components.push(prev_seg);
 
-    check_dims(&state, [300, 400], "jODEIwLQJDc=", "score_split");
+    check_dims(&state, [300, 400], "jOCAAQTAJjc=", "score_split");
 }
 
 #[test]
@@ -121,7 +117,7 @@ fn dark_layout() {
 
     check(
         &layout.state(&timer.snapshot()),
-        "T8IAQiBYxYc=",
+        "T8QIQABIw4c=",
         "dark_layout",
     );
 }
@@ -141,7 +137,7 @@ fn subsplits_layout() {
     check_dims(
         &layout.state(&timer.snapshot()),
         [300, 800],
-        "4/P76/Pz/88=",
+        "8/vz6/Pz/+c=",
         "subsplits_layout",
     );
 }
@@ -164,7 +160,7 @@ fn display_two_rows() {
     check_dims(
         &layout.state(&timer.snapshot()),
         [200, 100],
-        "QU0aWM1L9ME=",
+        "QU0aWs1J0sA=",
         "display_two_rows",
     );
 }
@@ -187,7 +183,7 @@ fn single_line_title() {
     check_dims(
         &layout.state(&timer.snapshot()),
         [300, 60],
-        "SDUKwUNqbQA=",
+        "ABJkm0toYZA=",
         "single_line_title",
     );
 }
@@ -221,7 +217,7 @@ fn horizontal() {
     check_dims(
         &layout.state(&timer.snapshot()),
         [1500, 40],
-        "YmJicmJSUmM=",
+        "YnJjcnJSUmM=",
         "horizontal",
     );
 }
@@ -231,9 +227,9 @@ fn get_comparison_tolerance() -> u32 {
     // tolerance that is greater than 0.
     // FIXME: We use SSE as an approximation for the cfg because MMX isn't supported by Rust yet.
     if cfg!(all(target_arch = "x86", not(target_feature = "sse"))) {
-        4
+        2
     } else {
-        1
+        0
     }
 }
 
@@ -243,8 +239,11 @@ fn check(state: &LayoutState, expected_hash_data: &str, name: &str) {
 }
 
 #[track_caller]
-fn check_dims(state: &LayoutState, dims: [usize; 2], expected_hash_data: &str, name: &str) {
-    let image = render(state, dims);
+fn check_dims(state: &LayoutState, dims: [u32; 2], expected_hash_data: &str, name: &str) {
+    let mut renderer = SoftwareRenderer::new();
+    renderer.render(state, dims);
+    let image = renderer.into_image();
+
     let hasher = HasherConfig::with_bytes_type::<[u8; 8]>().to_hasher();
 
     let calculated_hash = hasher.hash_image(&image);
@@ -257,7 +256,7 @@ fn check_dims(state: &LayoutState, dims: [usize; 2], expected_hash_data: &str, n
     let path = format!(
         "target/renders/{}_{}.png",
         name,
-        calculated_hash_data.replace("/", "$"),
+        calculated_hash_data.replace("/", "-"),
     );
     image.save(&path).ok();
 
@@ -267,9 +266,9 @@ fn check_dims(state: &LayoutState, dims: [usize; 2], expected_hash_data: &str, n
         let expected_path = format!(
             "target/renders/{}_{}.png",
             name,
-            expected_hash_data.replace("/", "$"),
+            expected_hash_data.replace("/", "-"),
         );
-        if let Ok(expected_image) = image::open(expected_path) {
+        let diff_path = if let Ok(expected_image) = image::open(&expected_path) {
             let mut expected_image = expected_image.to_rgba8();
             for (x, y, Rgba([r, g, b, a])) in expected_image.enumerate_pixels_mut() {
                 if x < image.width() && y < image.height() {
@@ -282,11 +281,24 @@ fn check_dims(state: &LayoutState, dims: [usize; 2], expected_hash_data: &str, n
             }
             let diff_path = format!("target/renders/diff/{}.png", name);
             expected_image.save(&diff_path).ok();
-        }
+            diff_path
+        } else {
+            String::from("Not found")
+        };
 
         panic!(
-            "Render mismatch for {} expected: {}, actual: {}, distance: {}",
-            name, expected_hash_data, calculated_hash_data, distance
+            "Render mismatch for {}
+expected: {} {}
+actual: {} {}
+diff: {}
+distance: {}",
+            name,
+            expected_hash_data,
+            expected_path,
+            calculated_hash_data,
+            path,
+            diff_path,
+            distance
         );
     }
 }