diff --git a/hui-glium/src/lib.rs b/hui-glium/src/lib.rs
index d75651b..ccafb3b 100644
--- a/hui-glium/src/lib.rs
+++ b/hui-glium/src/lib.rs
@@ -153,11 +153,11 @@ impl GliumUiRenderer {
   }
 
   pub fn update(&mut self, instance: &UiInstance) {
-    if self.ui_texture.is_none() || instance.atlas().modified {
-      self.update_texture_atlas(&instance.atlas());
+    if self.ui_texture.is_none() || instance.backend_atlas().modified {
+      self.update_texture_atlas(&instance.backend_atlas());
     }
-    if self.buffer_pair.is_none() || instance.draw_call().0 {
-      self.update_buffers(instance.draw_call().1);
+    if self.buffer_pair.is_none() || instance.backend_paint_buffer().0 {
+      self.update_buffers(instance.backend_paint_buffer().1);
     }
   }
 
diff --git a/hui-painter/src/backend.rs b/hui-painter/src/backend.rs
new file mode 100644
index 0000000..0ffdd02
--- /dev/null
+++ b/hui-painter/src/backend.rs
@@ -0,0 +1 @@
+// TODO
\ No newline at end of file
diff --git a/hui-painter/src/lib.rs b/hui-painter/src/lib.rs
index 1983e57..56c8e2a 100644
--- a/hui-painter/src/lib.rs
+++ b/hui-painter/src/lib.rs
@@ -2,6 +2,7 @@ pub mod paint;
 pub mod texture;
 pub mod text;
 pub mod util;
+pub mod backend;
 
 use text::FontManager;
 use texture::TextureAtlas;
@@ -9,8 +10,8 @@ use texture::TextureAtlas;
 /// Painter instance, stores textures and fonts needed for rendering
 #[derive(Default)]
 pub struct PainterInstance {
-  pub atlas: TextureAtlas,
-  pub fonts: FontManager,
+  pub(crate) textures: TextureAtlas,
+  pub(crate) fonts: FontManager,
 }
 
 impl PainterInstance {
@@ -18,6 +19,26 @@ impl PainterInstance {
     Self::default()
   }
 
+  /// Get an immutable reference to the texture atlas
+  pub fn textures(&self) -> &TextureAtlas {
+    &self.textures
+  }
+
+  /// Get a mutable reference to the texture atlas
+  pub fn textures_mut(&mut self) -> &mut TextureAtlas {
+    &mut self.textures
+  }
+
+  /// Get an immutable reference to the font manager
+  pub fn fonts(&self) -> &FontManager {
+    &self.fonts
+  }
+
+  /// Get a mutable reference to the font manager
+  pub fn fonts_mut(&mut self) -> &mut FontManager {
+    &mut self.fonts
+  }
+
   // pub fn atlas(&self) -> &TextureAtlas {
   //   &self.atlas
   // }
diff --git a/hui-painter/src/paint/buffer.rs b/hui-painter/src/paint/buffer.rs
index 8244049..84f1a34 100644
--- a/hui-painter/src/paint/buffer.rs
+++ b/hui-painter/src/paint/buffer.rs
@@ -18,6 +18,11 @@ impl PaintBuffer {
       indices: Vec::new(),
     }
   }
+
+  pub fn clear(&mut self) {
+    self.vertices.clear();
+    self.indices.clear();
+  }
 }
 
 impl Default for PaintBuffer {
diff --git a/hui-painter/src/paint/command.rs b/hui-painter/src/paint/command.rs
index 749649e..b459b9c 100644
--- a/hui-painter/src/paint/command.rs
+++ b/hui-painter/src/paint/command.rs
@@ -1,6 +1,3 @@
-use std::hash::Hash;
-
-use glam::Vec2;
 use hui_shared::rect::Rect;
 use crate::{paint::buffer::PaintBuffer, PainterInstance};
 
@@ -16,8 +13,7 @@ pub use transform::PaintTransform;
 mod rectangle;
 pub use rectangle::PaintRectangle;
 
-mod text;
-pub use text::PaintText;
+pub mod text;
 
 pub trait PaintCommand {
   /// Called before actual paint command is executed\
diff --git a/hui-painter/src/paint/command/list.rs b/hui-painter/src/paint/command/list.rs
index 658638b..4b48039 100644
--- a/hui-painter/src/paint/command/list.rs
+++ b/hui-painter/src/paint/command/list.rs
@@ -23,6 +23,10 @@ impl PaintList {
   pub fn add(&mut self, command: impl PaintCommand + 'static) {
     self.commands.push(Box::new(command));
   }
+
+  pub fn clear(&mut self) {
+    self.commands.clear();
+  }
 }
 
 impl Default for PaintList {
diff --git a/hui-painter/src/paint/command/rectangle.rs b/hui-painter/src/paint/command/rectangle.rs
index 42672d2..a3ca053 100644
--- a/hui-painter/src/paint/command/rectangle.rs
+++ b/hui-painter/src/paint/command/rectangle.rs
@@ -105,7 +105,7 @@ impl PaintCommand for PaintRectangle {
     // Otherwise, if texture handle is not set or invalid, use the bottom left
     // corner of the texture which contains a white pixel.
     let uvs = self.texture
-      .and_then(|handle| ctx.atlas.get_uv(handle))
+      .and_then(|handle| ctx.textures.get_uv(handle))
       .map(|global_uv| {
         let texture_uv = self.texture_uv;
         let texture_uv_is_default =
diff --git a/hui-painter/src/paint/command/text.rs b/hui-painter/src/paint/command/text.rs
index 251466f..58b76a2 100644
--- a/hui-painter/src/paint/command/text.rs
+++ b/hui-painter/src/paint/command/text.rs
@@ -66,7 +66,7 @@ impl PaintCommand for PaintText {
     let layout = self.build_layout(&font_array);
 
     for glyph in layout.glyphs() {
-      ctx.fonts.render_glyph(&mut ctx.atlas, self.text.font, glyph.key);
+      ctx.fonts.render_glyph(&mut ctx.textures, self.text.font, glyph.key);
     }
   }
 
@@ -89,28 +89,28 @@ impl PaintCommand for PaintText {
       let font_handle = self.text.font; // TODO use font_index here
 
       let vidx = into.vertices.len() as u32;
-      let glyph_texture = ctx.fonts.render_glyph(&mut ctx.atlas, font_handle, glyph.key);
-      let uv = ctx.atlas.get_uv(glyph_texture).unwrap();
+      let glyph_texture = ctx.fonts.render_glyph(&mut ctx.textures, font_handle, glyph.key);
+      let uv = ctx.textures.get_uv(glyph_texture).unwrap();
 
       into.indices.extend([vidx, vidx + 1, vidx + 2, vidx, vidx + 2, vidx + 3]);
       into.vertices.extend([
         Vertex {
-          position: vec2(glyph.x, glyph.y),
+          position: vec2(glyph.x, glyph.y).round(),
           color: self.text.color,
           uv: uv.top_left,
         },
         Vertex {
-          position: vec2(glyph.x + glyph_texture.size().x as f32, glyph.y),
+          position: vec2(glyph.x + glyph_texture.size().x as f32, glyph.y).round().round(),
           color: self.text.color,
           uv: uv.top_right,
         },
         Vertex {
-          position: vec2(glyph.x + glyph_texture.size().x as f32, glyph.y + glyph_texture.size().y as f32),
+          position: vec2(glyph.x + glyph_texture.size().x as f32, glyph.y + glyph_texture.size().y as f32).round(),
           color: self.text.color,
           uv: uv.bottom_right,
         },
         Vertex {
-          position: vec2(glyph.x, glyph.y + glyph_texture.size().y as f32),
+          position: vec2(glyph.x, glyph.y + glyph_texture.size().y as f32).round(),
           color: self.text.color,
           uv: uv.bottom_left,
         },
diff --git a/hui-painter/src/text.rs b/hui-painter/src/text.rs
index 03c51b7..f6398c4 100644
--- a/hui-painter/src/text.rs
+++ b/hui-painter/src/text.rs
@@ -19,10 +19,10 @@ impl FontManager {
     }
   }
 
-  /// Add a font to the manager.
+  /// Add a font to the manager from raw font file data.
   ///
   /// Panics:
-  /// - If the font data is invalid.
+  /// - If the font data is invalid or corrupted
   pub fn add(&mut self, data: &[u8]) -> FontHandle {
     let font = self.fonts.add_font(data);
     self.ftm.init_font(font);
diff --git a/hui-painter/src/texture.rs b/hui-painter/src/texture.rs
index df970b7..261f273 100644
--- a/hui-painter/src/texture.rs
+++ b/hui-painter/src/texture.rs
@@ -1,2 +1,417 @@
-mod atlas;
-pub use atlas::*;
+use glam::{ivec2, uvec2, vec2, UVec2, Vec2};
+use hui_shared::rect::Corners;
+use rect_packer::DensePacker;
+use hashbrown::HashMap;
+use nohash_hasher::BuildNoHashHasher;
+
+//TODO support rotation
+const DEFAULT_ATLAS_SIZE: UVec2 = uvec2(128, 128);
+// const ALLOW_ROTATION: bool = false;
+
+// Destination format is always RGBA
+const RGBA_BYTES_PER_PIXEL: usize = 4;
+
+/// Assert that the passed texture size is valid, panicking if it's not.
+///
+/// - The size must be greater than 0.
+/// - The size must be less than `i32::MAX`.
+fn assert_size(size: UVec2) {
+  assert!(
+    size.x > 0 &&
+    size.y > 0,
+    "size must be greater than 0"
+  );
+  assert!(
+    size.x <= i32::MAX as u32 &&
+    size.y <= i32::MAX as u32,
+    "size must be less than i32::MAX"
+  );
+}
+
+/// The format of the source texture data to use when updating a texture in the atlas.
+#[derive(Clone, Copy, Debug, Default)]
+pub enum SourceTextureFormat {
+  /// RGBA, 8-bit per channel
+  #[default]
+  RGBA8,
+
+  //TODO native-endian RGBA32 format
+
+  /// ARGB, 8-bit per channel
+  ARGB8,
+
+  /// BGRA, 8-bit per channel
+  BGRA8,
+
+  /// ABGR, 8-bit per channel
+  ABGR8,
+
+  /// RGB, 8-bit per channel (Alpha = 255)
+  RGB8,
+
+  /// BGR, 8-bit per channel (Alpha = 255)
+  BGR8,
+
+  /// Alpha only, 8-bit per channel (RGB = #ffffff)
+  A8,
+}
+
+impl SourceTextureFormat {
+  pub const fn bytes_per_pixel(&self) -> usize {
+    match self {
+      SourceTextureFormat::RGBA8 |
+      SourceTextureFormat::ARGB8 |
+      SourceTextureFormat::BGRA8 |
+      SourceTextureFormat::ABGR8 => 4,
+      SourceTextureFormat::RGB8 |
+      SourceTextureFormat::BGR8 => 3,
+      SourceTextureFormat::A8 => 1,
+    }
+  }
+}
+
+type TextureId = u32;
+
+/// A handle to a texture in the texture atlas.
+///
+/// Can be cheaply copied and passed around.\
+/// The handle is only valid for the texture atlas it was created from.
+#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)]
+pub struct TextureHandle {
+  pub(crate) id: TextureId,
+  pub(crate) size: UVec2,
+}
+
+impl TextureHandle {
+  /// Create a new broken texture handle.
+  pub fn new_broken() -> Self {
+    Self {
+      id: u32::MAX,
+      size: uvec2(0, 0),
+    }
+  }
+
+  pub fn size(&self) -> UVec2 {
+    self.size
+  }
+}
+
+/// Represents an area allocated to a specific texture handle in the texture atlas.
+struct TextureAllocation {
+  /// Corresponding copyable texture handle
+  handle: TextureHandle,
+
+  /// The offset of the allocation in the atlas, in pixels
+  offset: UVec2,
+
+  /// The requested size of the allocation, in pixels
+  size: UVec2,
+
+  /// The maximum size of the allocation, used for reusing deallocated allocations
+  ///
+  /// Usually equal to `size`, but may be larger than the requested size
+  /// if the allocation was reused by a smaller texture at some point
+  max_size: UVec2,
+}
+
+impl TextureAllocation {
+  /// Create a new texture allocation with the specified parameters.
+  ///
+  /// The `max_size` parameter will be set equal to `size`.
+  pub fn new(handle: TextureHandle, offset: UVec2, size: UVec2) -> Self {
+    Self {
+      handle,
+      offset,
+      size,
+      max_size: size,
+    }
+  }
+}
+
+pub struct TextureAtlasBackendData<'a> {
+  pub data: &'a [u8],
+  pub size: UVec2,
+  pub version: u64,
+}
+
+/// A texture atlas that can be used to pack multiple textures into a single texture.
+pub struct TextureAtlas {
+  /// The size of the atlas, in pixels
+  size: UVec2,
+
+  /// The texture data of the atlas, ALWAYS in RGBA8 format
+  data: Vec<u8>,
+
+  /// The packer used to allocate space for textures in the atlas
+  packer: DensePacker,
+
+  /// The next id to be used for a texture handle\
+  /// Gets incremented every time a new texture is allocated
+  next_id: TextureId,
+
+  /// Active allocated textures, indexed by id of their handle
+  allocations: HashMap<TextureId, TextureAllocation, BuildNoHashHasher<TextureId>>,
+
+  /// Deallocated allocations that can be reused, sorted by size
+  //TODO: use binary heap or btreeset for reuse_allocations instead, but this works for now
+  reuse_allocations: Vec<TextureAllocation>,
+
+  /// Version of the texture atlas, incremented every time the atlas is modified
+  version: u64,
+}
+
+impl TextureAtlas {
+  /// Create a new texture atlas with the specified size.
+  pub(crate) fn new(size: UVec2) -> Self {
+    assert_size(size);
+    let data_bytes = (size.x * size.y) as usize * RGBA_BYTES_PER_PIXEL;
+    Self {
+      size,
+      data: vec![0; data_bytes],
+      packer: DensePacker::new(
+        size.x as i32,
+        size.y as i32,
+      ),
+      next_id: 0,
+      allocations: HashMap::default(),
+      reuse_allocations: Vec::new(),
+      version: 0,
+    }
+  }
+
+  /// The version of the atlas, incremented every time the atlas is modified.
+  pub fn version(&self) -> u64 {
+    self.version
+  }
+
+  /// The underlying texture data of the atlas, in RGBA8 format.
+  pub fn data_rgba(&self) -> &[u8] {
+    &self.data
+  }
+
+  /// Get data needed by the backend implementation.
+  pub fn backend_data(&self) -> TextureAtlasBackendData {
+    TextureAtlasBackendData {
+      data: &self.data,
+      size: self.size,
+      version: self.version,
+    }
+  }
+
+  /// Increment the version of the atlas
+  fn increment_version(&mut self) {
+    // XXX: wrapping_add? will this *ever* overflow?
+    self.version = self.version.wrapping_add(1);
+  }
+
+  /// Get the next handle
+  ///
+  /// Does not allocate a texture associated with it
+  /// This handle will be invalid until it's associated with a texture.
+  ///
+  /// Used internally in `allocate` and `allocate_with_data`.
+  fn next_handle(&mut self, size: UVec2) -> TextureHandle {
+    let handle = TextureHandle {
+      id: self.next_id,
+      size,
+    };
+    self.next_id += 1;
+    handle
+  }
+
+  /// Allocate a texture in the atlas, returning a handle to it.\
+  /// The data present in the texture is undefined, and may include garbage data.
+  ///
+  /// # Panics
+  /// - If any of the dimensions of the texture are zero or exceed `i32::MAX`.
+  pub fn allocate(&mut self, size: UVec2) -> TextureHandle {
+    assert_size(size);
+
+    // Check if any deallocated allocations can be reused
+    // Find the smallest allocation that fits the requested size
+    // (The list is already sorted by size)
+    for (idx, allocation) in self.reuse_allocations.iter().enumerate() {
+      if allocation.max_size.x >= size.x && allocation.max_size.y >= size.y {
+        let allocation = self.reuse_allocations.remove(idx);
+        let handle = self.next_handle(size);
+        unsafe {
+          self.allocations.insert_unique_unchecked(handle.id, TextureAllocation {
+            handle,
+            offset: allocation.offset,
+            size,
+            max_size: allocation.max_size,
+          });
+        }
+        return handle;
+      }
+    }
+
+    // Pack the texture
+    let pack = self.packer.pack(
+      size.x as i32,
+      size.y as i32,
+      false
+    );
+
+    //TODO: handle pack failure by either resizing the atlas or returning an error
+    let pack = pack.unwrap();
+    let offset = ivec2(pack.x, pack.y).as_uvec2();
+
+    // Allocate the texture
+    let handle = self.next_handle(size);
+    let allocation = TextureAllocation::new(handle, offset, size);
+    unsafe {
+      self.allocations.insert_unique_unchecked(handle.id, allocation);
+    }
+
+    handle
+  }
+
+  /// Deallocate a texture in the atlas, allowing its space to be reused by future allocations.
+  ///
+  /// # Panics
+  /// - If the texture handle is invalid for this atlas.
+  pub fn deallocate(&mut self, handle: TextureHandle) {
+    // Remove the allocation from the active allocations
+    let allocation = self.allocations
+      .remove(&handle.id)
+      .expect("invalid texture handle");
+
+    // TODO: this is not the most efficient way to do this:
+    // And put it in the reuse allocations queue
+    self.reuse_allocations.push(allocation);
+    self.reuse_allocations.sort_unstable_by_key(|a| a.size.x * a.size.y);
+  }
+
+  /// Update the data of a texture in the atlas.\
+  /// The texture must have been previously allocated with `allocate` or `allocate_with_data`.
+  ///
+  /// The source data must be in the format specified by the `format` parameter.\
+  /// (Please note that the internal format of the texture is always RGBA8, regardless of the source format.)
+  ///
+  /// The function will silently ignore any data that doesn't fit in the texture.
+  ///
+  /// # Panics
+  /// - If the texture handle is invalid for this atlas.
+  /// - The length of the data array is less than the size of the texture.
+  pub fn update(&mut self, handle: TextureHandle, format: SourceTextureFormat, data: &[u8]) {
+    assert!(
+      data.len() >= handle.size.x as usize * handle.size.y as usize * format.bytes_per_pixel(),
+      "data length must be at least the size of the texture"
+    );
+
+    let bpp = format.bytes_per_pixel();
+
+    let TextureAllocation { size, offset, ..} = self.allocations
+      .get(&handle.id)
+      .expect("invalid texture handle");
+
+    for y in 0..size.y {
+      for x in 0..size.x {
+        let src_idx = (y * size.x + x) as usize * bpp;
+        let dst_idx: usize = (
+          (offset.y + y) * size.x +
+          (offset.x + x)
+        ) as usize * RGBA_BYTES_PER_PIXEL;
+
+        let src = &data[src_idx..src_idx + bpp];
+        let dst = &mut self.data[dst_idx..dst_idx + RGBA_BYTES_PER_PIXEL];
+
+        match format {
+          SourceTextureFormat::RGBA8 => {
+            dst.copy_from_slice(src);
+          },
+          SourceTextureFormat::ARGB8 => {
+            dst[..3].copy_from_slice(&src[1..]);
+            dst[3] = src[0];
+          },
+          SourceTextureFormat::BGRA8 => {
+            dst.copy_from_slice(src);
+            dst.rotate_right(1);
+            dst.reverse();
+          },
+          SourceTextureFormat::ABGR8 => {
+            dst.copy_from_slice(src);
+            dst.reverse();
+          },
+          SourceTextureFormat::RGB8 => {
+            dst[..3].copy_from_slice(src);
+            dst[3] = 0xff;
+          },
+          SourceTextureFormat::BGR8 => {
+            dst[..3].copy_from_slice(src);
+            dst[..3].reverse();
+            dst[3] = 0xff;
+          },
+          SourceTextureFormat::A8 => {
+            dst[..3].fill(0xff);
+            dst[3] = src[0];
+          },
+        }
+      }
+    }
+
+    self.increment_version();
+  }
+
+  /// Allocate a texture in the atlas, returning a handle to it.\
+  /// The texture is initialized with the provided data.
+  ///
+  /// The source data must be in the format specified by the `format` parameter.\
+  /// (Please note that the internal format of the texture is always RGBA8, regardless of the source format.)
+  ///
+  /// # Panics
+  /// - If any of the dimensions of the texture are zero or exceed `i32::MAX`.
+  /// - The length of the data array is zero or not a multiple of the stride (stride = width * bytes per pixel).
+  pub fn allocate_with_data(&mut self, format: SourceTextureFormat, data: &[u8], width: usize) -> TextureHandle {
+    assert!(
+      !data.is_empty(),
+      "texture data must not be empty"
+    );
+
+    // Calculate the stride of the texture
+    let bytes_per_pixel = format.bytes_per_pixel();
+    let stride = bytes_per_pixel * width;
+    assert_eq!(
+      data.len() % stride, 0,
+      "texture data must be a multiple of the stride",
+    );
+
+    // Calculate the size of the texture
+    let size = uvec2(
+      width as u32,
+      (data.len() / stride) as u32,
+    );
+    assert_size(size);
+
+    // Allocate the texture
+    let handle = self.allocate(size);
+
+    // Write the data to the texture
+    self.update(handle, format, data);
+
+    handle
+  }
+
+  /// Get uv coordinates for the texture handle.
+  pub(crate) fn get_uv(&self, handle: TextureHandle) -> Option<Corners<Vec2>> {
+    let TextureAllocation { offset, size, .. } = self.allocations
+      .get(&handle.id)?;
+    let p0x = offset.x as f32 / self.size.x as f32;
+    let p1x = (offset.x as f32 + size.x as f32) / self.size.x as f32;
+    let p0y = offset.y as f32 / self.size.y as f32;
+    let p1y = (offset.y as f32 + size.y as f32) / self.size.y as f32;
+    Some(Corners {
+      top_left: vec2(p0x, p0y),
+      top_right: vec2(p1x, p0y),
+      bottom_left: vec2(p0x, p1y),
+      bottom_right: vec2(p1x, p1y),
+    })
+  }
+}
+
+impl Default for TextureAtlas {
+  fn default() -> Self {
+    Self::new(DEFAULT_ATLAS_SIZE)
+  }
+}
diff --git a/hui-painter/src/texture/atlas.rs b/hui-painter/src/texture/atlas.rs
deleted file mode 100644
index 55e5cc9..0000000
--- a/hui-painter/src/texture/atlas.rs
+++ /dev/null
@@ -1,387 +0,0 @@
-use glam::{ivec2, uvec2, vec2, UVec2, Vec2};
-use hui_shared::rect::Corners;
-use rect_packer::DensePacker;
-use hashbrown::HashMap;
-use nohash_hasher::BuildNoHashHasher;
-
-//TODO support rotation
-const DEFAULT_ATLAS_SIZE: UVec2 = uvec2(128, 128);
-// const ALLOW_ROTATION: bool = false;
-
-// Destination format is always RGBA
-const RGBA_BYTES_PER_PIXEL: usize = 4;
-
-/// Assert that the passed texture size is valid, panicking if it's not.
-///
-/// - The size must be greater than 0.
-/// - The size must be less than `i32::MAX`.
-fn assert_size(size: UVec2) {
-  assert!(
-    size.x > 0 &&
-    size.y > 0,
-    "size must be greater than 0"
-  );
-  assert!(
-    size.x <= i32::MAX as u32 &&
-    size.y <= i32::MAX as u32,
-    "size must be less than i32::MAX"
-  );
-}
-
-/// The format of the source texture data to use when updating a texture in the atlas.
-#[derive(Clone, Copy, Debug, Default)]
-pub enum SourceTextureFormat {
-  /// RGBA, 8-bit per channel
-  #[default]
-  RGBA8,
-
-  //TODO native-endian RGBA32 format
-
-  /// ARGB, 8-bit per channel
-  ARGB8,
-
-  /// BGRA, 8-bit per channel
-  BGRA8,
-
-  /// ABGR, 8-bit per channel
-  ABGR8,
-
-  /// RGB, 8-bit per channel (Alpha = 255)
-  RGB8,
-
-  /// BGR, 8-bit per channel (Alpha = 255)
-  BGR8,
-
-  /// Alpha only, 8-bit per channel (RGB = #ffffff)
-  A8,
-}
-
-impl SourceTextureFormat {
-  pub const fn bytes_per_pixel(&self) -> usize {
-    match self {
-      SourceTextureFormat::RGBA8 |
-      SourceTextureFormat::ARGB8 |
-      SourceTextureFormat::BGRA8 |
-      SourceTextureFormat::ABGR8 => 4,
-      SourceTextureFormat::RGB8 |
-      SourceTextureFormat::BGR8 => 3,
-      SourceTextureFormat::A8 => 1,
-    }
-  }
-}
-
-type TextureId = u32;
-
-/// A handle to a texture in the texture atlas.
-///
-/// Can be cheaply copied and passed around.\
-/// The handle is only valid for the texture atlas it was created from.
-#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)]
-pub struct TextureHandle {
-  pub(crate) id: TextureId,
-  pub(crate) size: UVec2,
-}
-
-impl TextureHandle {
-  pub fn size(&self) -> UVec2 {
-    self.size
-  }
-}
-
-/// Represents an area allocated to a specific texture handle in the texture atlas.
-struct TextureAllocation {
-  /// Corresponding copyable texture handle
-  handle: TextureHandle,
-
-  /// The offset of the allocation in the atlas, in pixels
-  offset: UVec2,
-
-  /// The requested size of the allocation, in pixels
-  size: UVec2,
-
-  /// The maximum size of the allocation, used for reusing deallocated allocations
-  ///
-  /// Usually equal to `size`, but may be larger than the requested size
-  /// if the allocation was reused by a smaller texture at some point
-  max_size: UVec2,
-}
-
-impl TextureAllocation {
-  /// Create a new texture allocation with the specified parameters.
-  ///
-  /// The `max_size` parameter will be set equal to `size`.
-  pub fn new(handle: TextureHandle, offset: UVec2, size: UVec2) -> Self {
-    Self {
-      handle,
-      offset,
-      size,
-      max_size: size,
-    }
-  }
-}
-
-/// A texture atlas that can be used to pack multiple textures into a single texture.
-pub struct TextureAtlas {
-  /// The size of the atlas, in pixels
-  size: UVec2,
-
-  /// The texture data of the atlas, ALWAYS in RGBA8 format
-  data: Vec<u8>,
-
-  /// The packer used to allocate space for textures in the atlas
-  packer: DensePacker,
-
-  /// The next id to be used for a texture handle\
-  /// Gets incremented every time a new texture is allocated
-  next_id: TextureId,
-
-  /// Active allocated textures, indexed by id of their handle
-  allocations: HashMap<TextureId, TextureAllocation, BuildNoHashHasher<TextureId>>,
-
-  /// Deallocated allocations that can be reused, sorted by size
-  //TODO: use binary heap or btreeset for reuse_allocations instead, but this works for now
-  reuse_allocations: Vec<TextureAllocation>,
-
-  /// Version of the texture atlas, incremented every time the atlas is modified
-  version: u64,
-}
-
-impl TextureAtlas {
-  /// Create a new texture atlas with the specified size.
-  pub(crate) fn new(size: UVec2) -> Self {
-    assert_size(size);
-    let data_bytes = (size.x * size.y) as usize * RGBA_BYTES_PER_PIXEL;
-    Self {
-      size,
-      data: vec![0; data_bytes],
-      packer: DensePacker::new(
-        size.x as i32,
-        size.y as i32,
-      ),
-      next_id: 0,
-      allocations: HashMap::default(),
-      reuse_allocations: Vec::new(),
-      version: 0,
-    }
-  }
-
-  pub fn version(&self) -> u64 {
-    self.version
-  }
-
-  fn mark_modified(&mut self) {
-    // XXX: wrapping_add? will this *ever* overflow?
-    self.version = self.version.wrapping_add(1);
-  }
-
-  /// Get the next handle
-  ///
-  /// Does not allocate a texture associated with it
-  /// This handle will be invalid until it's associated with a texture.
-  ///
-  /// Used internally in `allocate` and `allocate_with_data`.
-  fn next_handle(&mut self, size: UVec2) -> TextureHandle {
-    let handle = TextureHandle {
-      id: self.next_id,
-      size,
-    };
-    self.next_id += 1;
-    handle
-  }
-
-  /// Allocate a texture in the atlas, returning a handle to it.\
-  /// The data present in the texture is undefined, and may include garbage data.
-  ///
-  /// # Panics
-  /// - If any of the dimensions of the texture are zero or exceed `i32::MAX`.
-  pub fn allocate(&mut self, size: UVec2) -> TextureHandle {
-    assert_size(size);
-
-    // Check if any deallocated allocations can be reused
-    // Find the smallest allocation that fits the requested size
-    // (The list is already sorted by size)
-    for (idx, allocation) in self.reuse_allocations.iter().enumerate() {
-      if allocation.max_size.x >= size.x && allocation.max_size.y >= size.y {
-        let allocation = self.reuse_allocations.remove(idx);
-        let handle = self.next_handle(size);
-        unsafe {
-          self.allocations.insert_unique_unchecked(handle.id, TextureAllocation {
-            handle,
-            offset: allocation.offset,
-            size,
-            max_size: allocation.max_size,
-          });
-        }
-        return handle;
-      }
-    }
-
-    // Pack the texture
-    let pack = self.packer.pack(
-      size.x as i32,
-      size.y as i32,
-      false
-    );
-
-    //TODO: handle pack failure by either resizing the atlas or returning an error
-    let pack = pack.unwrap();
-    let offset = ivec2(pack.x, pack.y).as_uvec2();
-
-    // Allocate the texture
-    let handle = self.next_handle(size);
-    let allocation = TextureAllocation::new(handle, offset, size);
-    unsafe {
-      self.allocations.insert_unique_unchecked(handle.id, allocation);
-    }
-
-    handle
-  }
-
-  /// Deallocate a texture in the atlas, allowing its space to be reused by future allocations.
-  ///
-  /// # Panics
-  /// - If the texture handle is invalid for this atlas.
-  pub fn deallocate(&mut self, handle: TextureHandle) {
-    // Remove the allocation from the active allocations
-    let allocation = self.allocations
-      .remove(&handle.id)
-      .expect("invalid texture handle");
-
-    // TODO: this is not the most efficient way to do this:
-    // And put it in the reuse allocations queue
-    self.reuse_allocations.push(allocation);
-    self.reuse_allocations.sort_unstable_by_key(|a| a.size.x * a.size.y);
-  }
-
-  /// Update the data of a texture in the atlas.\
-  /// The texture must have been previously allocated with `allocate` or `allocate_with_data`.
-  ///
-  /// The source data must be in the format specified by the `format` parameter.\
-  /// (Please note that the internal format of the texture is always RGBA8, regardless of the source format.)
-  ///
-  /// The function will silently ignore any data that doesn't fit in the texture.
-  ///
-  /// # Panics
-  /// - If the texture handle is invalid for this atlas.
-  /// - The length of the data array is less than the size of the texture.
-  pub fn update(&mut self, handle: TextureHandle, format: SourceTextureFormat, data: &[u8]) {
-    assert!(
-      data.len() >= handle.size.x as usize * handle.size.y as usize * format.bytes_per_pixel(),
-      "data length must be at least the size of the texture"
-    );
-
-    let bpp = format.bytes_per_pixel();
-
-    let TextureAllocation { size, offset, ..} = self.allocations
-      .get(&handle.id)
-      .expect("invalid texture handle");
-
-    for y in 0..size.y {
-      for x in 0..size.x {
-        let src_idx = (y * size.x + x) as usize * bpp;
-        let dst_idx: usize = (
-          (offset.y + y) * size.x +
-          (offset.x + x)
-        ) as usize * RGBA_BYTES_PER_PIXEL;
-
-        let src = &data[src_idx..src_idx + bpp];
-        let dst = &mut self.data[dst_idx..dst_idx + RGBA_BYTES_PER_PIXEL];
-
-        match format {
-          SourceTextureFormat::RGBA8 => {
-            dst.copy_from_slice(src);
-          },
-          SourceTextureFormat::ARGB8 => {
-            dst[..3].copy_from_slice(&src[1..]);
-            dst[3] = src[0];
-          },
-          SourceTextureFormat::BGRA8 => {
-            dst.copy_from_slice(src);
-            dst.rotate_right(1);
-            dst.reverse();
-          },
-          SourceTextureFormat::ABGR8 => {
-            dst.copy_from_slice(src);
-            dst.reverse();
-          },
-          SourceTextureFormat::RGB8 => {
-            dst[..3].copy_from_slice(src);
-            dst[3] = 0xff;
-          },
-          SourceTextureFormat::BGR8 => {
-            dst[..3].copy_from_slice(src);
-            dst[..3].reverse();
-            dst[3] = 0xff;
-          },
-          SourceTextureFormat::A8 => {
-            dst[..3].fill(0xff);
-            dst[3] = src[0];
-          },
-        }
-      }
-    }
-
-    self.mark_modified();
-  }
-
-  /// Allocate a texture in the atlas, returning a handle to it.\
-  /// The texture is initialized with the provided data.
-  ///
-  /// The source data must be in the format specified by the `format` parameter.\
-  /// (Please note that the internal format of the texture is always RGBA8, regardless of the source format.)
-  ///
-  /// # Panics
-  /// - If any of the dimensions of the texture are zero or exceed `i32::MAX`.
-  /// - The length of the data array is zero or not a multiple of the stride (stride = width * bytes per pixel).
-  pub fn allocate_with_data(&mut self, format: SourceTextureFormat, data: &[u8], width: usize) -> TextureHandle {
-    assert!(
-      !data.is_empty(),
-      "texture data must not be empty"
-    );
-
-    // Calculate the stride of the texture
-    let bytes_per_pixel = format.bytes_per_pixel();
-    let stride = bytes_per_pixel * width;
-    assert_eq!(
-      data.len() % stride, 0,
-      "texture data must be a multiple of the stride",
-    );
-
-    // Calculate the size of the texture
-    let size = uvec2(
-      width as u32,
-      (data.len() / stride) as u32,
-    );
-    assert_size(size);
-
-    // Allocate the texture
-    let handle = self.allocate(size);
-
-    // Write the data to the texture
-    self.update(handle, format, data);
-
-    handle
-  }
-
-  /// Get uv coordinates for the texture handle.
-  pub(crate) fn get_uv(&self, handle: TextureHandle) -> Option<Corners<Vec2>> {
-    let TextureAllocation { offset, size, .. } = self.allocations
-      .get(&handle.id)?;
-    let p0x = offset.x as f32 / self.size.x as f32;
-    let p1x = (offset.x as f32 + size.x as f32) / self.size.x as f32;
-    let p0y = offset.y as f32 / self.size.y as f32;
-    let p1y = (offset.y as f32 + size.y as f32) / self.size.y as f32;
-    Some(Corners {
-      top_left: vec2(p0x, p0y),
-      top_right: vec2(p1x, p0y),
-      bottom_left: vec2(p0x, p1y),
-      bottom_right: vec2(p1x, p1y),
-    })
-  }
-}
-
-impl Default for TextureAtlas {
-  fn default() -> Self {
-    Self::new(DEFAULT_ATLAS_SIZE)
-  }
-}
diff --git a/hui-wgpu/src/lib.rs b/hui-wgpu/src/lib.rs
index 3c2fbed..389f5d0 100644
--- a/hui-wgpu/src/lib.rs
+++ b/hui-wgpu/src/lib.rs
@@ -290,12 +290,12 @@ impl WgpuUiRenderer {
     device: &wgpu::Device,
     resolution: Vec2,
   ) {
-    let (modified, call) = instance.draw_call();
+    let (modified, call) = instance.backend_paint_buffer();
     if self.modified || modified {
       self.update_buffers(call, queue, device, resolution);
     }
 
-    let meta = instance.atlas();
+    let meta = instance.backend_atlas();
     if self.modified || meta.modified {
       self.update_texture(meta, queue, device);
     }
diff --git a/hui/Cargo.toml b/hui/Cargo.toml
index d948023..e15eda8 100644
--- a/hui/Cargo.toml
+++ b/hui/Cargo.toml
@@ -18,7 +18,7 @@ include = [
 [dependencies]
 hui-derive = { version = "0.1.0-alpha.6", path = "../hui-derive", optional = true }
 hui-shared = { version = "0.1.0-alpha.6", path = "../hui-shared" }
-# hui-painter = { version = "0.1.0-alpha.6", path = "../hui-painter" }
+hui-painter = { version = "0.1.0-alpha.6", path = "../hui-painter" }
 hashbrown = "0.15"
 nohash-hasher = "0.2"
 glam = "0.30"
@@ -33,7 +33,7 @@ image = { version = "0.25", default-features = false, optional = true }
 rustc-hash = "2.0"
 
 [features]
-default = ["el_all", "image", "builtin_font", "pixel_perfect_text", "derive"]
+default = ["el_all", "image", "builtin_font", "derive"]
 
 ## Enable derive macros
 derive = ["dep:hui-derive"]
@@ -44,17 +44,6 @@ image = ["dep:image"]
 ## Enable the built-in font (ProggyTiny, adds *35kb* to the executable)
 builtin_font = []
 
-#! #### Pixel-perfect rendering:
-
-## Round all vertex positions to nearest integer coordinates (not recommended)
-pixel_perfect = ["pixel_perfect_text"]
-
-## Apply pixel-perfect rendering hack to text (fixes blurry text rendering)
-pixel_perfect_text = []
-
-#! Make sure to disable both features if you are not rendering UI "as-is" at 1:1 scale\
-#! For exmaple, you should disable them if using DPI (or any other form of) scaling while passing the virtual resolution to the ui or rendering it in 3d space
-
 #! #### Built-in elements:
 
 ## Enable all built-in elements
diff --git a/hui/src/draw.rs b/hui/src/draw.rs
deleted file mode 100644
index 580da98..0000000
--- a/hui/src/draw.rs
+++ /dev/null
@@ -1,396 +0,0 @@
-//! draw commands, tesselation and UI rendering.
-
-use crate::{
-  rect::Corners,
-  text::{FontHandle, TextRenderer}
-};
-
-pub(crate) mod atlas;
-use atlas::TextureAtlasManager;
-pub use atlas::{ImageHandle, TextureAtlasMeta, TextureFormat, ImageCtx};
-
-mod corner_radius;
-pub use corner_radius::RoundedCorners;
-
-use std::borrow::Cow;
-use fontdue::layout::{Layout, CoordinateSystem, TextStyle};
-use glam::{vec2, Vec2, Affine2, Vec4};
-
-//TODO: circle draw command
-
-/// Available draw commands
-/// - Rectangle: Filled, colored rectangle, with optional rounded corners and texture
-/// - Text: Draw text using the specified font, size, color, and position
-#[derive(Clone, Debug, PartialEq)]
-pub enum UiDrawCommand {
-  ///Filled, colored rectangle
-  Rectangle {
-    ///Position in pixels
-    position: Vec2,
-    ///Size in pixels
-    size: Vec2,
-    ///Color (RGBA)
-    color: Corners<Vec4>,
-    ///Texture
-    texture: Option<ImageHandle>,
-    ///Sub-UV coordinates for the texture
-    texture_uv: Option<Corners<Vec2>>,
-    ///Rounded corners
-    rounded_corners: Option<RoundedCorners>,
-  },
-  /// Draw text using the specified font, size, color, and position
-  Text {
-    ///Position in pixels
-    position: Vec2,
-    ///Font size
-    size: u16,
-    ///Color (RGBA)
-    color: Vec4,
-    ///Text to draw
-    text: Cow<'static, str>,
-    ///Font handle to use
-    font: FontHandle,
-  },
-  /// Push a transformation matrix to the stack
-  PushTransform(Affine2),
-  /// Pop a transformation matrix from the stack
-  PopTransform,
-  //TODO PushClip PopClip
-}
-
-/// List of draw commands
-#[derive(Default)]
-pub struct UiDrawCommandList {
-  pub commands: Vec<UiDrawCommand>,
-}
-
-impl UiDrawCommandList {
-  /// Add a draw command to the list
-  pub fn add(&mut self, command: UiDrawCommand) {
-    self.commands.push(command);
-  }
-}
-
-// impl UiDrawCommands {
-//   pub fn compare(&self, other: &Self) -> bool {
-//     // if self.commands.len() != other.commands.len() { return false }
-//     // self.commands.iter().zip(other.commands.iter()).all(|(a, b)| a == b)
-//   }
-// }
-
-/// A vertex for UI rendering
-#[derive(Clone, Copy, Debug, PartialEq, Default)]
-pub struct UiVertex {
-  pub position: Vec2,
-  pub color: Vec4,
-  pub uv: Vec2,
-}
-
-/// Represents a single draw call (vertices + indices), should be handled by the render backend
-#[derive(Default)]
-pub struct UiDrawCall {
-  pub vertices: Vec<UiVertex>,
-  pub indices: Vec<u32>,
-}
-
-impl UiDrawCall {
-  /// Tesselate the UI and build a complete draw plan from a list of draw commands
-  pub(crate) fn build(draw_commands: &UiDrawCommandList, atlas: &mut TextureAtlasManager, text_renderer: &mut TextRenderer) -> Self {
-    let mut trans_stack = Vec::new();
-    let mut draw_call = UiDrawCall::default();
-
-    //HACK: atlas may get resized while creating new glyphs,
-    //which invalidates all uvs, causing corrupted-looking texture
-    //so we need to pregenerate font textures before generating any vertices
-    //we are doing *a lot* of double work here, but it's the easiest way to avoid the issue
-    for comamnd in &draw_commands.commands {
-      if let UiDrawCommand::Text { text, font: font_handle, size, .. } = comamnd {
-        let mut layout = Layout::new(CoordinateSystem::PositiveYDown);
-        layout.append(
-          &[text_renderer.internal_font(*font_handle)],
-          &TextStyle::new(text, *size as f32, 0)
-        );
-        let glyphs = layout.glyphs();
-        for layout_glyph in glyphs {
-          if !layout_glyph.char_data.rasterize() { continue }
-          text_renderer.glyph(atlas, *font_handle, layout_glyph.parent, layout_glyph.key.px as u8);
-        }
-      }
-    }
-
-    //note to future self:
-    //RESIZING OR ADDING STUFF TO ATLAS AFTER THIS POINT IS A BIG NO-NO,
-    //DON'T DO IT EVER AGAIN UNLESS YOU WANT TO SPEND HOURS DEBUGGING
-
-    atlas.lock_atlas = true;
-
-    for command in &draw_commands.commands {
-      match command {
-        UiDrawCommand::PushTransform(trans) => {
-          //Take note of the current index, and the transformation matrix\
-          //We will actually apply the transformation matrix when we pop it,
-          //to all vertices between the current index and the index we pushed
-          trans_stack.push((trans, draw_call.vertices.len() as u32));
-        },
-        UiDrawCommand::PopTransform => {
-          //Pop the transformation matrix and apply it to all vertices between the current index and the index we pushed
-          let (&trans, idx) = trans_stack.pop().expect("Unbalanced push/pop transform");
-
-          //If Push is immediately followed by a pop (which is dumb but possible), we don't need to do anything
-          //(this can also happen if push and pop are separated by a draw command that doesn't add any vertices, like a text command with an empty string)
-          if idx == draw_call.vertices.len() as u32 {
-            continue
-          }
-
-          //Kinda a hack:
-          //We want to apply the transform aronnd the center, so we need to compute the center of the vertices
-          //We won't actually do that, we will compute the center of the bounding box of the vertices
-          let mut min = Vec2::splat(f32::INFINITY);
-          let mut max = Vec2::splat(f32::NEG_INFINITY);
-          for v in &draw_call.vertices[idx as usize..] {
-            min = min.min(v.position);
-            max = max.max(v.position);
-          }
-          //TODO: make the point of transform configurable
-          let center = (min + max) / 2.;
-
-          //Apply trans mtx to all vertices between idx and the current index
-          for v in &mut draw_call.vertices[idx as usize..] {
-            v.position -= center;
-            v.position = trans.transform_point2(v.position);
-            v.position += center;
-          }
-        },
-        UiDrawCommand::Rectangle { position, size, color, texture, texture_uv, rounded_corners } => {
-          let uvs = texture
-            .map(|x| atlas.get_uv(x))
-            .flatten()
-            .map(|guv| {
-              if let Some(texture_uv) = texture_uv {
-                //XXX: this may not work if the texture is rotated
-                //also is this slow?
-
-                let top = guv.top_left.lerp(guv.top_right, texture_uv.top_left.x);
-                let bottom = guv.bottom_left.lerp(guv.bottom_right, texture_uv.top_left.x);
-                let top_left = top.lerp(bottom, texture_uv.top_left.y);
-
-                let top = guv.top_left.lerp(guv.top_right, texture_uv.top_right.x);
-                let bottom = guv.bottom_left.lerp(guv.bottom_right, texture_uv.top_right.x);
-                let top_right = top.lerp(bottom, texture_uv.top_right.y);
-
-                let top = guv.top_left.lerp(guv.top_right, texture_uv.bottom_left.x);
-                let bottom = guv.bottom_left.lerp(guv.bottom_right, texture_uv.bottom_left.x);
-                let bottom_left = top.lerp(bottom, texture_uv.bottom_left.y);
-
-                let top = guv.top_left.lerp(guv.top_right, texture_uv.bottom_right.x);
-                let bottom = guv.bottom_left.lerp(guv.bottom_right, texture_uv.bottom_right.x);
-                let bottom_right = top.lerp(bottom, texture_uv.bottom_right.y);
-
-                Corners { top_left, top_right, bottom_left, bottom_right }
-              } else {
-                guv
-              }
-            })
-            .unwrap_or(Corners::all(Vec2::ZERO));
-
-          let vidx = draw_call.vertices.len() as u32;
-          if let Some(corner) = rounded_corners.filter(|x| x.radius.max_f32() > 0.0) {
-            //this code is stupid as fuck
-            //but it works... i think?
-            //maybe some verts end up missing, but it's close enough...
-
-            //Random vert in the center for no reason
-            //lol
-            draw_call.vertices.push(UiVertex {
-              position: *position + *size * vec2(0.5, 0.5),
-              color: (color.bottom_left + color.bottom_right + color.top_left + color.top_right) / 4.,
-              //TODO: fix this uv
-              uv: vec2(0., 0.),
-            });
-
-            //TODO: fix some corners tris being invisible (but it's already close enough lol)
-            let rounded_corner_verts = corner.point_count.get() as u32;
-            for i in 0..rounded_corner_verts {
-              let cratio = i as f32 / (rounded_corner_verts - 1) as f32;
-              let angle = cratio * std::f32::consts::PI * 0.5;
-              let x = angle.sin();
-              let y = angle.cos();
-
-              let mut corner_impl = |rp: Vec2, color: &Corners<Vec4>| {
-                let rrp = rp / *size;
-                let color_at_point =
-                  color.bottom_right * rrp.x * rrp.y +
-                  color.top_right * rrp.x * (1. - rrp.y) +
-                  color.bottom_left * (1. - rrp.x) * rrp.y +
-                  color.top_left * (1. - rrp.x) * (1. - rrp.y);
-                let uv_at_point =
-                  uvs.bottom_right * rrp.x * rrp.y +
-                  uvs.top_right * rrp.x * (1. - rrp.y) +
-                  uvs.bottom_left * (1. - rrp.x) * rrp.y +
-                  uvs.top_left * (1. - rrp.x) * (1. - rrp.y);
-                draw_call.vertices.push(UiVertex {
-                  position: *position + rp,
-                  color: color_at_point,
-                  uv: uv_at_point,
-                });
-              };
-
-              //Top-right corner
-              corner_impl(
-                vec2(x, 1. - y) * corner.radius.top_right + vec2(size.x - corner.radius.top_right, 0.),
-                color,
-              );
-              //Bottom-right corner
-              corner_impl(
-                vec2(x - 1., y) * corner.radius.bottom_right + vec2(size.x, size.y - corner.radius.bottom_right),
-                color,
-              );
-              //Bottom-left corner
-              corner_impl(
-                vec2(1. - x, y) * corner.radius.bottom_left + vec2(0., size.y - corner.radius.bottom_left),
-                color,
-              );
-              //Top-left corner
-              corner_impl(
-                vec2(1. - x, 1. - y) * corner.radius.top_left,
-                color,
-              );
-
-              // mental illness:
-              if i > 0 {
-                draw_call.indices.extend([
-                  //Top-right corner
-                  vidx,
-                  vidx + 1 + (i - 1) * 4,
-                  vidx + 1 + i * 4,
-                  //Bottom-right corner
-                  vidx,
-                  vidx + 1 + (i - 1) * 4 + 1,
-                  vidx + 1 + i * 4 + 1,
-                  //Bottom-left corner
-                  vidx,
-                  vidx + 1 + (i - 1) * 4 + 2,
-                  vidx + 1 + i * 4 + 2,
-                  //Top-left corner
-                  vidx,
-                  vidx + 1 + (i - 1) * 4 + 3,
-                  vidx + 1 + i * 4 + 3,
-                ]);
-              }
-            }
-            //Fill in the rest
-            //mental illness 2:
-            draw_call.indices.extend([
-              //Top
-              vidx,
-              vidx + 4,
-              vidx + 1,
-              //Right?, i think
-              vidx,
-              vidx + 1 + (rounded_corner_verts - 1) * 4,
-              vidx + 1 + (rounded_corner_verts - 1) * 4 + 1,
-              //Left???
-              vidx,
-              vidx + 1 + (rounded_corner_verts - 1) * 4 + 2,
-              vidx + 1 + (rounded_corner_verts - 1) * 4 + 3,
-              //Bottom???
-              vidx,
-              vidx + 3,
-              vidx + 2,
-            ]);
-          } else {
-            //...Normal rectangle
-            draw_call.indices.extend([vidx, vidx + 1, vidx + 2, vidx, vidx + 2, vidx + 3]);
-            draw_call.vertices.extend([
-              UiVertex {
-                position: *position,
-                color: color.top_left,
-                uv: uvs.top_left,
-              },
-              UiVertex {
-                position: *position + vec2(size.x, 0.0),
-                color: color.top_right,
-                uv: uvs.top_right,
-              },
-              UiVertex {
-                position: *position + *size,
-                color: color.bottom_right,
-                uv: uvs.bottom_right,
-              },
-              UiVertex {
-                position: *position + vec2(0.0, size.y),
-                color: color.bottom_left,
-                uv: uvs.bottom_left,
-              },
-            ]);
-          }
-        },
-        UiDrawCommand::Text { position, size, color, text, font: font_handle } => {
-          if text.is_empty() {
-            continue
-          }
-
-          //XXX: should we be doing this every time?
-          let mut layout = Layout::new(CoordinateSystem::PositiveYDown);
-          layout.append(
-            &[text_renderer.internal_font(*font_handle)],
-            &TextStyle::new(text, *size as f32, 0)
-          );
-          let glyphs = layout.glyphs();
-
-          for layout_glyph in glyphs {
-            if !layout_glyph.char_data.rasterize() {
-              continue
-            }
-            let vidx = draw_call.vertices.len() as u32;
-            let glyph = text_renderer.glyph(atlas, *font_handle, layout_glyph.parent, layout_glyph.key.px as u8);
-            let uv = atlas.get_uv(glyph.texture).unwrap();
-            draw_call.indices.extend([vidx, vidx + 1, vidx + 2, vidx, vidx + 2, vidx + 3]);
-            draw_call.vertices.extend([
-              UiVertex {
-                position: *position + vec2(layout_glyph.x, layout_glyph.y),
-                color: *color,
-                uv: uv.top_left,
-              },
-              UiVertex {
-                position: *position + vec2(layout_glyph.x + glyph.metrics.width as f32, layout_glyph.y),
-                color: *color,
-                uv: uv.top_right,
-              },
-              UiVertex {
-                position: *position + vec2(layout_glyph.x + glyph.metrics.width as f32, layout_glyph.y + glyph.metrics.height as f32),
-                color: *color,
-                uv: uv.bottom_right,
-              },
-              UiVertex {
-                position: *position + vec2(layout_glyph.x, layout_glyph.y + glyph.metrics.height as f32),
-                color: *color,
-                uv: uv.bottom_left,
-              },
-            ]);
-            #[cfg(all(
-              feature = "pixel_perfect_text",
-              not(feature = "pixel_perfect")
-            ))] {
-              //Round the position of the vertices to the nearest pixel, unless any transformations are active
-              if trans_stack.is_empty() {
-                for vtx in &mut draw_call.vertices[(vidx as usize)..] {
-                  vtx.position = vtx.position.round()
-                }
-              }
-            }
-          }
-        }
-      }
-    }
-
-    atlas.lock_atlas = false;
-
-    #[cfg(feature = "pixel_perfect")]
-    draw_call.vertices.iter_mut().for_each(|v| {
-      v.position = v.position.round()
-    });
-
-    draw_call
-  }
-}
diff --git a/hui/src/draw/atlas.rs b/hui/src/draw/atlas.rs
deleted file mode 100644
index 73bc2aa..0000000
--- a/hui/src/draw/atlas.rs
+++ /dev/null
@@ -1,299 +0,0 @@
-use glam::{uvec2, vec2, UVec2, Vec2};
-use hashbrown::HashMap;
-use nohash_hasher::BuildNoHashHasher;
-use rect_packer::DensePacker;
-use crate::rect::Corners;
-
-const RGBA_CHANNEL_COUNT: u32 = 4;
-//TODO make this work
-const ALLOW_ROTATION: bool = false;
-
-/// Texture format of the source texture data
-#[derive(Default, Clone, Copy, PartialEq, Eq)]
-pub enum TextureFormat {
-  /// The data is stored in RGBA format, with 1 byte (8 bits) per channel
-  #[default]
-  Rgba,
-
-  /// The data is copied into the Alpha channel, with 1 byte (8 bits) per channel\
-  /// Remaining channels are set to 255 (which can be easily shaded to any color)
-  ///
-  /// This format is useful for storing grayscale textures such as icons\
-  /// (Please note that the internal representation is still RGBA, this is just a convenience feature)
-  Grayscale,
-}
-
-/// Contains a reference to the texture data, and metadata associated with it
-pub struct TextureAtlasMeta<'a> {
-  /// Texture data\
-  /// The data is stored in RGBA format, with 1 byte (8 bits) per channel
-  pub data: &'a [u8],
-  /// Current size of the texture atlas\
-  /// Please note that this value might change
-  pub size: UVec2,
-  /// True if the atlas has been modified since the beginning of the current frame\
-  /// If this function returns true, the texture atlas should be re-uploaded to the GPU before rendering\
-  pub modified: bool,
-}
-
-/// Texture handle, stores the internal index of a texture within the texture atlas and can be cheaply copied.
-///
-/// Please note that dropping a handle does not deallocate the texture from the atlas, you must do it manually.
-///
-/// Only valid for the `UiInstance` that created it.\
-/// Using it with other instances may result in panics or unexpected behavior.
-///
-/// Handle values are not guaranteed to be valid.\
-/// Creating or transmuting an invalid handle is allowed and is *not* UB.
-///
-/// Internal value is an implementation detail and should not be relied upon.
-#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
-pub struct ImageHandle {
-  pub(crate) index: u32,
-}
-
-#[derive(Clone, Copy, Debug)]
-pub(crate) struct TextureAllocation {
-  /// Position in the texture atlas\
-  /// (This is an implementation detail and should not be exposed to the user)
-  pub(crate) position: UVec2,
-
-  /// Requested texture size
-  pub size: UVec2,
-
-  /// True if the texture was rotated by 90 degrees\
-  /// (This is an implementation detail and should not be exposed to the user)
-  pub(crate) rotated: bool,
-}
-
-/// Manages a texture atlas and the allocation of space within it\
-/// The atlas is alllowed to grow and resize dynamically, as needed
-pub(crate) struct TextureAtlasManager {
-  packer: DensePacker,
-  count: u32,
-  size: UVec2,
-  data: Vec<u8>,
-  allocations: HashMap<u32, TextureAllocation, BuildNoHashHasher<u32>>,
-  /// Items that have been removed from the allocation list, but still affect
-  remove_queue: Vec<TextureAllocation>,
-  /// True if the atlas has been modified in a way which requires a texture reupload
-  /// since the beginning of the current frame
-  modified: bool,
-
-  /// If true, attempting to modify the atlas in a way which invalidates UVs will cause a panic\
-  /// Used internally to ensure that the UVs do not become invalidated mid-render
-  pub(crate) lock_atlas: bool,
-}
-
-impl TextureAtlasManager {
-  /// Create a new texture atlas with the specified size\
-  /// 512x512 is a good default size for most applications, and the texture atlas can grow dynamically as needed
-  pub fn new(size: UVec2) -> Self {
-    Self {
-      packer: DensePacker::new(size.x as i32, size.y as i32),
-      count: 0,
-      size,
-      data: vec![0; (size.x * size.y * RGBA_CHANNEL_COUNT) as usize],
-      allocations: HashMap::default(),
-      remove_queue: Vec::new(),
-      modified: true,
-      lock_atlas: false,
-    }
-  }
-
-  /// Resize the texture atlas to the new size in-place, preserving the existing data
-  pub fn resize(&mut self, new_size: UVec2) {
-    if self.lock_atlas {
-      panic!("Attempted to resize the texture atlas while the atlas is locked");
-    }
-    log::trace!("resizing texture atlas to {:?}", new_size);
-    if self.size == new_size {
-      log::warn!("Texture atlas is already the requested size");
-      return
-    }
-    if new_size.x > self.size.x && new_size.y > self.size.y {
-      self.packer.resize(new_size.x as i32, new_size.y as i32);
-      //Resize the data array in-place
-      self.data.resize((new_size.x * new_size.y * RGBA_CHANNEL_COUNT) as usize, 0);
-      for y in (0..self.size.y).rev() {
-        for x in (1..self.size.x).rev() {
-          let idx = ((y * self.size.x + x) * RGBA_CHANNEL_COUNT) as usize;
-          let new_idx = ((y * new_size.x + x) * RGBA_CHANNEL_COUNT) as usize;
-          for c in 0..(RGBA_CHANNEL_COUNT as usize) {
-            self.data[new_idx + c] = self.data[idx + c];
-          }
-        }
-      }
-    } else {
-      //If scaling down, just recreate the atlas from scratch (since we need to re-pack everything anyway)
-      todo!("Atlas downscaling is not implemented yet");
-    }
-    self.size = new_size;
-    self.modified = true;
-  }
-
-  /// Ensure that a texture with specified size would fit without resizing on the next allocation attempt\
-  pub fn ensure_fits(&mut self, size: UVec2) {
-    // Plan A: try if any of the existing items in the remove queue would fit the texture
-    // Plan B: purge the remove queue, recreate the packer and try again (might be expensive...!)
-    // TODO: implement these
-    // Plan C: resize the atlas
-    let mut new_size = self.size;
-    while !self.packer.can_pack(size.x as i32, size.y as i32, ALLOW_ROTATION) {
-      new_size *= 2;
-      self.packer.resize(new_size.x as i32, new_size.y as i32);
-    }
-    if new_size != self.size {
-      self.resize(new_size);
-    }
-  }
-
-  /// Allocate a new texture region in the atlas and return a handle to it\
-  /// Returns None if the texture could not be allocated due to lack of space\
-  /// Use `allocate` to allocate a texture and resize the atlas if necessary\
-  /// Does not modify the texture data
-  fn try_allocate(&mut self, size: UVec2) -> Option<ImageHandle> {
-    log::trace!("Allocating texture of size {:?}", size);
-    let result = self.packer.pack(size.x as i32, size.y as i32, ALLOW_ROTATION)?;
-    let index = self.count;
-    self.count += 1;
-    let allocation = TextureAllocation {
-      position: UVec2::new(result.x as u32, result.y as u32),
-      size,
-      //If the size does not match the requested size, the texture was rotated
-      rotated: ALLOW_ROTATION && (result.width != size.x as i32),
-    };
-    unsafe {
-      self.allocations.insert_unique_unchecked(index, allocation);
-    }
-    Some(ImageHandle { index })
-  }
-
-  /// Allocate a new texture region in the atlas and resize the atlas if necessary\
-  /// This function should never fail under normal circumstances.\
-  /// May modify the texture data if the atlas is resized
-  pub fn allocate(&mut self, size: UVec2) -> ImageHandle {
-    self.ensure_fits(size);
-    self.try_allocate(size).unwrap()
-  }
-
-  /// Allocate a new texture region in the atlas and copy the data into it\
-  /// This function may resize the atlas as needed, and should never fail under normal circumstances.
-  pub(crate) fn add_rgba(&mut self, width: usize, data: &[u8]) -> ImageHandle {
-    let size = uvec2(width as u32, (data.len() / (width * RGBA_CHANNEL_COUNT as usize)) as u32);
-    let handle: ImageHandle = self.allocate(size);
-    let allocation = self.allocations.get(&handle.index).unwrap();
-    assert!(!allocation.rotated, "Rotated textures are not implemented yet");
-    for y in 0..size.y {
-      for x in 0..size.x {
-        let src_idx = (y * size.x + x) * RGBA_CHANNEL_COUNT;
-        let dst_idx = ((allocation.position.y + y) * self.size.x + allocation.position.x + x) * RGBA_CHANNEL_COUNT;
-        for c in 0..RGBA_CHANNEL_COUNT as usize {
-          self.data[dst_idx as usize + c] = data[src_idx as usize + c];
-        }
-      }
-    }
-    self.modified = true;
-    handle
-  }
-
-  /// Works the same way as [`TextureAtlasManager::add`], but the input data is assumed to be grayscale (1 channel per pixel)\
-  /// The data is copied into the alpha channel of the texture, while all the other channels are set to 255\
-  /// May resize the atlas as needed, and should never fail under normal circumstances.
-  pub(crate) fn add_grayscale(&mut self, width: usize, data: &[u8]) -> ImageHandle {
-    let size = uvec2(width as u32, (data.len() / width) as u32);
-    let handle = self.allocate(size);
-    let allocation = self.allocations.get(&handle.index).unwrap();
-    assert!(!allocation.rotated, "Rotated textures are not implemented yet");
-    for y in 0..size.y {
-      for x in 0..size.x {
-        let src_idx = (y * size.x + x) as usize;
-        let dst_idx = (((allocation.position.y + y) * self.size.x + allocation.position.x + x) * RGBA_CHANNEL_COUNT) as usize;
-        self.data[dst_idx..(dst_idx + RGBA_CHANNEL_COUNT as usize)].copy_from_slice(&[255, 255, 255, data[src_idx]]);
-      }
-    }
-    self.modified = true;
-    handle
-  }
-
-  pub fn add(&mut self, width: usize, data: &[u8], format: TextureFormat) -> ImageHandle {
-    match format {
-      TextureFormat::Rgba => self.add_rgba(width, data),
-      TextureFormat::Grayscale => self.add_grayscale(width, data),
-    }
-  }
-
-  pub(crate) fn add_dummy(&mut self) {
-    let handle = self.allocate((1, 1).into());
-    assert!(handle.index == 0, "Dummy texture handle is not 0");
-    assert!(self.get(handle).unwrap().position == (0, 0).into(), "Dummy texture position is not (0, 0)");
-    self.data[0..4].copy_from_slice(&[255, 255, 255, 255]);
-    self.modified = true;
-  }
-
-  pub fn modify(&mut self, handle: ImageHandle) {
-    todo!()
-  }
-
-  pub fn remove(&mut self, handle: ImageHandle) {
-    todo!()
-  }
-
-  pub fn get(&self, handle: ImageHandle) -> Option<&TextureAllocation> {
-    self.allocations.get(&handle.index)
-  }
-
-  pub(crate) fn get_uv(&self, handle: ImageHandle) -> Option<Corners<Vec2>> {
-    let info = self.get(handle)?;
-    let atlas_size = self.meta().size.as_vec2();
-    let p0x = info.position.x as f32 / atlas_size.x;
-    let p1x = (info.position.x as f32 + info.size.x as f32) / atlas_size.x;
-    let p0y = info.position.y as f32 / atlas_size.y;
-    let p1y = (info.position.y as f32 + info.size.y as f32) / atlas_size.y;
-    Some(Corners {
-      top_left: vec2(p0x, p0y),
-      top_right: vec2(p1x, p0y),
-      bottom_left: vec2(p0x, p1y),
-      bottom_right: vec2(p1x, p1y),
-    })
-  }
-
-  /// Reset the `is_modified` flag
-  pub(crate) fn reset_modified(&mut self) {
-    self.modified = false;
-  }
-
-  pub fn meta(&self) -> TextureAtlasMeta {
-    TextureAtlasMeta {
-      data: &self.data,
-      size: self.size,
-      modified: self.modified,
-    }
-  }
-
-  pub fn context(&self) -> ImageCtx {
-    ImageCtx { atlas: self }
-  }
-}
-
-impl Default for TextureAtlasManager {
-  /// Create a new texture atlas with a default size of 512x512
-  fn default() -> Self {
-    Self::new(UVec2::new(512, 512))
-  }
-}
-
-/// Context that allows read-only accss to image metadata
-#[derive(Clone, Copy)]
-pub struct ImageCtx<'a> {
-  pub(crate) atlas: &'a TextureAtlasManager,
-}
-
-impl ImageCtx<'_> {
-  /// Get size of the image with the specified handle
-  ///
-  /// Returns None if the handle is invalid for the current context
-  pub fn get_size(&self, handle: ImageHandle) -> Option<UVec2> {
-    self.atlas.get(handle).map(|a| a.size)
-  }
-}
diff --git a/hui/src/draw/corner_radius.rs b/hui/src/draw/corner_radius.rs
deleted file mode 100644
index 4448712..0000000
--- a/hui/src/draw/corner_radius.rs
+++ /dev/null
@@ -1,55 +0,0 @@
-use std::num::NonZeroU16;
-use crate::rect::Corners;
-
-//TODO uneven corners (separate width/height for each corner)
-
-/// Calculate the number of points based on the maximum corner radius
-fn point_count(corners: Corners<f32>) -> NonZeroU16 {
-  //Increase for higher quality
-  const VTX_PER_CORER_RADIUS_PIXEL: f32 = 0.5;
-  NonZeroU16::new(
-    (corners.max_f32() * VTX_PER_CORER_RADIUS_PIXEL).round() as u16 + 2
-  ).unwrap()
-}
-
-/// Low-level options for rendering rounded corners
-#[derive(Clone, Copy, Debug, PartialEq)]
-pub struct RoundedCorners {
-  /// Corner radius of each corner
-  pub radius: Corners<f32>,
-
-  /// Number of points to use for each corner
-  ///
-  /// This value affects all corners, regardless of their individual radius
-  pub point_count: NonZeroU16,
-}
-
-impl From<Corners<f32>> for RoundedCorners {
-  /// Create a new `RoundedCorners` from [`Corners<f32>`](crate::rect::Corners)
-  ///
-  /// Point count will be calculated automatically based on the maximum radius
-  fn from(radius: Corners<f32>) -> Self {
-    Self::from_radius(radius)
-  }
-}
-
-impl RoundedCorners {
-  /// Create a new `RoundedCorners` from [`Corners<f32>`](crate::rect::Corners)
-  ///
-  /// Point count will be calculated automatically based on the maximum radius
-  pub fn from_radius(radius: Corners<f32>) -> Self {
-    Self {
-      radius,
-      point_count: point_count(radius),
-    }
-  }
-}
-
-impl Default for RoundedCorners {
-  fn default() -> Self {
-    Self {
-      radius: Corners::default(),
-      point_count: NonZeroU16::new(8).unwrap(),
-    }
-  }
-}
diff --git a/hui/src/element.rs b/hui/src/element.rs
index 1d31f1d..7cc1a5f 100644
--- a/hui/src/element.rs
+++ b/hui/src/element.rs
@@ -1,40 +1,37 @@
 //! element API and built-in elements like `Container`, `Button`, `Text`, etc.
 
 use crate::{
-  draw::{atlas::ImageCtx, UiDrawCommandList},
   input::InputCtx,
   layout::{LayoutInfo, Size2d},
   measure::Response,
   rect::Rect,
   signal::SignalStore,
   state::StateRepo,
-  text::{FontHandle, TextMeasure},
   UiInstance,
 };
 
 mod builtin;
 pub use builtin::*;
+use hui_painter::{paint::command::PaintList, text::FontHandle, PainterInstance};
 
 /// Context for the `Element::measure` function
 pub struct MeasureContext<'a> {
+  pub painter: &'a PainterInstance,
+  pub current_font: FontHandle,
   pub layout: &'a LayoutInfo,
   pub state: &'a StateRepo,
-  pub text_measure: TextMeasure<'a>,
-  pub current_font: FontHandle,
-  pub images: ImageCtx<'a>,
   //XXX: should measure have a reference to input?
   //pub input: InputCtx<'a>,
 }
 
 /// Context for the `Element::process` function
 pub struct ProcessContext<'a> {
+  pub painter: &'a mut PainterInstance,
+  pub paint_target: &'a mut PaintList,
   pub measure: &'a Response,
   pub layout: &'a LayoutInfo,
-  pub draw: &'a mut UiDrawCommandList,
   pub state: &'a mut StateRepo,
-  pub text_measure: TextMeasure<'a>,
   pub current_font: FontHandle,
-  pub images: ImageCtx<'a>,
   pub input: InputCtx<'a>,
   pub signal: &'a mut SignalStore,
 }
diff --git a/hui/src/element/builtin/container.rs b/hui/src/element/builtin/container.rs
index 0c70905..d6dd13e 100644
--- a/hui/src/element/builtin/container.rs
+++ b/hui/src/element/builtin/container.rs
@@ -187,7 +187,8 @@ impl UiElement for Container {
         }
       }
 
-      let measure = element.measure(MeasureContext{
+      let measure = element.measure(MeasureContext {
+        painter: ctx.painter,
         state: ctx.state,
         layout: &LayoutInfo {
           //XXX: if the element gets wrapped, this will be inaccurate.
@@ -201,9 +202,7 @@ impl UiElement for Container {
           direction: self.direction,
           remaining_space: None,
         },
-        text_measure: ctx.text_measure,
         current_font: ctx.current_font,
-        images: ctx.images,
       });
 
       //Check the position of the side of element closest to the end on the primary axis
@@ -375,7 +374,7 @@ impl UiElement for Container {
     //   });
     // }
 
-    self.background_frame.draw(ctx.draw, (ctx.layout.position, ctx.measure.size).into());
+    self.background_frame.draw(ctx.paint_target, (ctx.layout.position, ctx.measure.size).into());
 
     //padding
     position += vec2(self.padding.left, self.padding.top);
@@ -444,11 +443,10 @@ impl UiElement for Container {
 
         //measure
         let el_measure = element.measure(MeasureContext {
+          painter: ctx.painter,
           layout: &el_layout,
           state: ctx.state,
-          text_measure: ctx.text_measure,
           current_font: ctx.current_font,
-          images: ctx.images,
         });
 
         //align (on sec. axis)
@@ -485,13 +483,12 @@ impl UiElement for Container {
 
         //process
         element.process(ProcessContext {
+          painter: ctx.painter,
           measure: &el_measure,
           layout: &el_layout,
-          draw: ctx.draw,
+          paint_target: ctx.paint_target,
           state: ctx.state,
-          text_measure: ctx.text_measure,
           current_font: ctx.current_font,
-          images: ctx.images,
           input: ctx.input,
           signal: ctx.signal,
         });
diff --git a/hui/src/element/builtin/frame_view.rs b/hui/src/element/builtin/frame_view.rs
index d6b1e1f..fed8191 100644
--- a/hui/src/element/builtin/frame_view.rs
+++ b/hui/src/element/builtin/frame_view.rs
@@ -63,6 +63,6 @@ impl UiElement for FrameView {
   }
 
   fn process(&self, ctx: ProcessContext) {
-    self.frame.draw(ctx.draw, (ctx.layout.position, ctx.measure.size).into());
+    self.frame.draw(ctx.paint_target, (ctx.layout.position, ctx.measure.size).into());
   }
 }
diff --git a/hui/src/element/builtin/image.rs b/hui/src/element/builtin/image.rs
index 87c1a53..fb7a83a 100644
--- a/hui/src/element/builtin/image.rs
+++ b/hui/src/element/builtin/image.rs
@@ -1,7 +1,7 @@
 use derive_setters::Setters;
-use glam::vec2;
+use glam::{vec2, Affine2};
+use hui_painter::{paint::command::{PaintRectangle, PaintTransform}, texture::TextureHandle};
 use crate::{
-  draw::{ImageHandle, RoundedCorners, UiDrawCommand},
   element::{MeasureContext, ProcessContext, UiElement},
   layout::{compute_size, Size, Size2d},
   measure::Response,
@@ -13,7 +13,7 @@ use crate::{
 pub struct Image {
   /// Image handle to draw
   #[setters(skip)]
-  pub image: ImageHandle,
+  pub image: TextureHandle,
 
   /// Size of the image.
   ///
@@ -36,7 +36,7 @@ pub struct Image {
 }
 
 impl Image {
-  pub fn new(handle: ImageHandle) -> Self {
+  pub fn new(handle: TextureHandle) -> Self {
     Self {
       image: handle,
       size: Size2d {
@@ -59,7 +59,7 @@ impl UiElement for Image {
   }
 
   fn measure(&self, ctx: MeasureContext) -> Response {
-    let dim = ctx.images.get_size(self.image).expect("invalid image handle");
+    let dim = self.image.size();
     let pre_size = compute_size(ctx.layout, self.size, dim.as_vec2());
     Response {
       size: compute_size(ctx.layout, self.size, vec2(
@@ -78,16 +78,17 @@ impl UiElement for Image {
 
   fn process(&self, ctx: ProcessContext) {
     if !self.color.is_transparent() {
-      ctx.draw.add(UiDrawCommand::Rectangle {
-        position: ctx.layout.position,
-        size: ctx.measure.size,
-        color: self.color.corners(),
-        texture: Some(self.image),
-        texture_uv: None,
-        rounded_corners: (self.corner_radius.max_f32() > 0.).then_some({
-          RoundedCorners::from_radius(self.corner_radius)
-        }),
-      });
+      ctx.paint_target.add(
+        PaintTransform {
+          transform: Affine2::from_translation(ctx.layout.position),
+          child: PaintRectangle {
+            size: ctx.measure.size,
+            color: self.color,
+            texture: Some(self.image),
+            ..Default::default()
+          },
+        }
+      );
     }
   }
 }
diff --git a/hui/src/element/builtin/progress_bar.rs b/hui/src/element/builtin/progress_bar.rs
index a432956..8003c82 100644
--- a/hui/src/element/builtin/progress_bar.rs
+++ b/hui/src/element/builtin/progress_bar.rs
@@ -75,10 +75,10 @@ impl UiElement for ProgressBar {
 
     //FIXME: these optimizations may not be valid
     if value < 1. || !self.foreground.covers_opaque() {
-      self.background.draw(ctx.draw, (ctx.layout.position, ctx.measure.size).into());
+      self.background.draw(ctx.paint_target, (ctx.layout.position, ctx.measure.size).into());
     }
     if value > 0. {
-      self.foreground.draw(ctx.draw, (ctx.layout.position, ctx.measure.size * vec2(value, 1.)).into());
+      self.foreground.draw(ctx.paint_target, (ctx.layout.position, ctx.measure.size * vec2(value, 1.)).into());
     }
 
     // let rounded_corners =
diff --git a/hui/src/element/builtin/slider.rs b/hui/src/element/builtin/slider.rs
index 14a5710..c4396ca 100644
--- a/hui/src/element/builtin/slider.rs
+++ b/hui/src/element/builtin/slider.rs
@@ -158,7 +158,7 @@ impl UiElement for Slider {
     // if !(self.track_color.is_transparent() || (self.track_active_color.is_opaque() && self.handle_color.is_opaque() && self.value >= 1.)) {
     if !(self.track_active.covers_opaque() && self.handle.covers_opaque() && (self.handle_size.1 >= self.track_height) && self.value >= 1.) {
       self.track.draw(
-        ctx.draw,
+        ctx.paint_target,
         (
           ctx.layout.position + ctx.measure.size * vec2(0., 0.5 - self.track_height / 2.),
           ctx.measure.size * vec2(1., self.track_height),
@@ -172,7 +172,7 @@ impl UiElement for Slider {
     // if !(self.track_active_color.is_transparent() || (self.value <= 0. && self.handle_color.is_opaque())) {
     if !(self.handle.covers_opaque() && (self.handle_size.1 >= self.track_height) && self.value <= 0.) {
       self.track_active.draw(
-        ctx.draw,
+        ctx.paint_target,
         (
           ctx.layout.position + ctx.measure.size * vec2(0., 0.5 - self.track_height / 2.),
           (ctx.measure.size - handle_size * Vec2::X) * vec2(self.value, self.track_height) + handle_size * Vec2::X / 2.,
@@ -193,7 +193,7 @@ impl UiElement for Slider {
     // }
     if (self.handle_size.0 > 0. && self.handle_size.1 > 0.) {
       self.handle.draw(
-        ctx.draw,
+        ctx.paint_target,
         (
           ctx.layout.position +
             ((ctx.measure.size.x - handle_size.x) * self.value) * Vec2::X +
diff --git a/hui/src/element/builtin/text.rs b/hui/src/element/builtin/text.rs
index 303c859..8c5e2a0 100644
--- a/hui/src/element/builtin/text.rs
+++ b/hui/src/element/builtin/text.rs
@@ -2,16 +2,17 @@
 
 use std::borrow::Cow;
 use derive_setters::Setters;
-use glam::Vec4;
+use glam::{Affine2, Vec4};
+use hui_painter::{
+  paint::command::{text::{PaintText, TextChunk}, PaintCommand, PaintTransform},
+  text::FontHandle,
+};
 use crate::{
-  draw::UiDrawCommand,
   element::{MeasureContext, ProcessContext, UiElement},
   layout::{compute_size, Size, Size2d},
   measure::Response,
-  text::FontHandle,
 };
 
-
 //TODO: text fit
 // pub enum TextSize {
 //   FitToWidthRatio(f32),
@@ -41,7 +42,7 @@ pub struct Text {
   pub font: Option<FontHandle>,
 
   /// Size of the text, in points (these are not pixels)
-  pub text_size: u16,
+  pub text_size: f32,
 }
 
 impl Default for Text {
@@ -51,7 +52,7 @@ impl Default for Text {
       size: (Size::Auto, Size::Auto).into(),
       color: Vec4::new(1., 1., 1., 1.),
       font: None,
-      text_size: 16,
+      text_size: 16.,
     }
   }
 }
@@ -69,6 +70,19 @@ impl Text {
   }
 }
 
+impl Text {
+  fn paint_cmd(&self, current_font: FontHandle) -> PaintText {
+    PaintText {
+      text: TextChunk {
+        text: self.text.clone(),
+        font: self.font.unwrap_or(current_font),
+        size: self.text_size as f32,
+        color: self.color.into(),
+      }
+    }
+  }
+}
+
 impl UiElement for Text {
   fn name(&self) -> &'static str {
     "text"
@@ -82,9 +96,13 @@ impl UiElement for Text {
     let mut size = (0., 0.);
     if matches!(self.size.width, Size::Auto) || matches!(self.size.height, Size::Auto) {
       //TODO optimized measure if only one of the sizes is auto
-      let res = ctx.text_measure.measure(self.font(ctx.current_font), self.text_size, &self.text);
-      size.0 = res.max_width;
-      size.1 = res.height;
+      // let res = ctx.text_measure.measure(self.font(ctx.current_font), self.text_size, &self.text);
+      // size.0 = res.max_width;
+      // size.1 = res.height;
+      let cmd = self.paint_cmd(ctx.current_font);
+      let cmd_size = cmd.bounds(ctx.painter).size;
+      size.0 = cmd_size.x;
+      size.1 = cmd_size.y;
     }
     Response {
       size: compute_size(ctx.layout, self.size, size.into()),
@@ -96,12 +114,9 @@ impl UiElement for Text {
     if self.text.is_empty() || self.color.w == 0. {
       return
     }
-    ctx.draw.add(UiDrawCommand::Text {
-      text: self.text.clone(),
-      position: ctx.layout.position,
-      size: self.text_size,
-      color: self.color,
-      font: self.font(ctx.current_font),
+    ctx.paint_target.add(PaintTransform {
+      transform: Affine2::from_translation(ctx.layout.position),
+      child: self.paint_cmd(ctx.current_font),
     });
   }
 }
diff --git a/hui/src/element/builtin/transformer.rs b/hui/src/element/builtin/transformer.rs
index 0467ae0..f6f35f6 100644
--- a/hui/src/element/builtin/transformer.rs
+++ b/hui/src/element/builtin/transformer.rs
@@ -1,8 +1,10 @@
 //! wrapper that allows applying various transformations to an element, such as translation, rotation, or scaling
 
 use glam::{Affine2, Vec2};
+use hui_painter::paint::command::{PaintList, PaintTransform};
 use crate::{
-  draw::UiDrawCommand, element::{MeasureContext, ProcessContext, UiElement}, measure::Response
+  element::{MeasureContext, ProcessContext, UiElement},
+  measure::Response,
 };
 
 pub struct Transformer {
@@ -46,20 +48,27 @@ impl UiElement for Transformer {
   }
 
   fn process(&self, ctx: ProcessContext) {
-    ctx.draw.add(UiDrawCommand::PushTransform(self.transform));
-    //This is stupid:
+    if self.transform == Affine2::IDENTITY {
+      self.element.process(ctx);
+      return;
+    }
+
+    let mut sub_list = PaintList::new_empty();
     self.element.process(ProcessContext {
+      painter: ctx.painter,
       measure: ctx.measure,
       state: ctx.state,
       layout: ctx.layout,
-      draw: ctx.draw,
-      text_measure: ctx.text_measure,
+      paint_target: &mut sub_list,
       current_font: ctx.current_font,
-      images: ctx.images,
       input: ctx.input,
       signal: ctx.signal,
     });
-    ctx.draw.add(UiDrawCommand::PopTransform);
+
+    ctx.paint_target.add(PaintTransform {
+      transform: self.transform,
+      child: sub_list,
+    });
   }
 }
 
diff --git a/hui/src/text/stack.rs b/hui/src/font.rs
similarity index 51%
rename from hui/src/text/stack.rs
rename to hui/src/font.rs
index a61f5e9..65d2ee3 100644
--- a/hui/src/text/stack.rs
+++ b/hui/src/font.rs
@@ -1,4 +1,5 @@
-use super::FontHandle;
+
+use hui_painter::text::FontHandle;
 
 pub struct FontStack {
   fonts: Vec<FontHandle>,
@@ -7,10 +8,12 @@ pub struct FontStack {
 impl FontStack {
   pub fn new() -> Self {
     Self {
-      #[cfg(not(feature = "builtin_font"))]
       fonts: Vec::new(),
-      #[cfg(feature = "builtin_font")]
-      fonts: vec![super::BUILTIN_FONT],
+      // TODO builtin_font
+      // #[cfg(not(feature = "builtin_font"))]
+      // fonts: Vec::new(),
+      // #[cfg(feature = "builtin_font")]
+      // fonts: vec![super::BUILTIN_FONT],
     }
   }
 
@@ -26,7 +29,7 @@ impl FontStack {
     self.fonts.last().copied()
   }
 
-  pub fn current_or_default(&self) -> FontHandle {
-    self.current().unwrap_or_default()
-  }
+  // pub fn current_or_default(&self) -> FontHandle {
+  //   self.current().unwrap_or_default()
+  // }
 }
diff --git a/hui/src/frame.rs b/hui/src/frame.rs
index 64e3237..2708cd6 100644
--- a/hui/src/frame.rs
+++ b/hui/src/frame.rs
@@ -1,6 +1,7 @@
 //! modular procedural background system
 
-use crate::{draw::UiDrawCommandList, rect::Rect};
+use crate::rect::Rect;
+use hui_painter::paint::command::PaintList;
 
 pub mod point;
 mod rect;
@@ -9,11 +10,10 @@ pub mod nine_patch;
 mod impls;
 
 pub use rect::RectFrame;
-
 /// Trait for a drawable frame
 pub trait Frame {
   /// Draw the frame at the given rect's position and size
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect);
+  fn draw(&self, draw: &mut PaintList, rect: Rect);
 
   /// Check if the frame is guaranteed to be fully opaque and fully cover the parent frame regardless of it's size
   ///
diff --git a/hui/src/frame/impls.rs b/hui/src/frame/impls.rs
index b906816..c38a78b 100644
--- a/hui/src/frame/impls.rs
+++ b/hui/src/frame/impls.rs
@@ -1,21 +1,22 @@
-use glam::{Vec3, Vec4};
+use glam::{Affine2, Vec3, Vec4};
+use hui_painter::{paint::command::{PaintList, PaintRectangle, PaintTransform}, texture::TextureHandle};
 use super::Frame;
 use crate::{
   color,
-  draw::{ImageHandle, UiDrawCommand, UiDrawCommandList},
   rect::{Rect, Corners, FillColor},
 };
 
-impl Frame for ImageHandle {
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect) {
-    draw.add(UiDrawCommand::Rectangle {
-      position: rect.position,
-      size: rect.size,
-      color: color::WHITE.into(),
-      texture: Some(*self),
-      texture_uv: None,
-      rounded_corners: None,
-    })
+impl Frame for TextureHandle {
+  fn draw(&self, draw: &mut PaintList, rect: Rect) {
+    draw.add(PaintTransform {
+      transform: Affine2::from_translation(rect.position),
+      child: PaintRectangle {
+        size: rect.size.into(),
+        color: color::WHITE.into(),
+        texture: Some(*self),
+        ..Default::default()
+      },
+    });
   }
 
   fn covers_opaque(&self) -> bool {
@@ -24,17 +25,17 @@ impl Frame for ImageHandle {
 }
 
 impl Frame for FillColor {
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect) {
+  fn draw(&self, draw: &mut PaintList, rect: Rect) {
     if self.is_transparent() {
       return
     }
-    draw.add(UiDrawCommand::Rectangle {
-      position: rect.position,
-      size: rect.size,
-      color: self.corners(),
-      texture: None,
-      texture_uv: None,
-      rounded_corners: None,
+    draw.add(PaintTransform {
+      transform: Affine2::from_translation(rect.position),
+      child: PaintRectangle {
+        size: rect.size,
+        color: *self,
+        ..Default::default()
+      },
     })
   }
 
@@ -48,7 +49,7 @@ impl Frame for FillColor {
 // Corners (RGBA):
 
 impl Frame for Corners<Vec4> {
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect) {
+  fn draw(&self, draw: &mut PaintList, rect: Rect) {
     FillColor::from(*self).draw(draw, rect)
   }
   fn covers_opaque(&self) -> bool {
@@ -57,7 +58,7 @@ impl Frame for Corners<Vec4> {
 }
 
 impl Frame for (Vec4, Vec4, Vec4, Vec4) {
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect) {
+  fn draw(&self, draw: &mut PaintList, rect: Rect) {
     FillColor::from(*self).draw(draw, rect)
   }
   fn covers_opaque(&self) -> bool {
@@ -66,7 +67,7 @@ impl Frame for (Vec4, Vec4, Vec4, Vec4) {
 }
 
 impl Frame for ((f32, f32, f32, f32), (f32, f32, f32, f32), (f32, f32, f32, f32), (f32, f32, f32, f32)) {
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect) {
+  fn draw(&self, draw: &mut PaintList, rect: Rect) {
     FillColor::from(*self).draw(draw, rect)
   }
   fn covers_opaque(&self) -> bool {
@@ -75,7 +76,7 @@ impl Frame for ((f32, f32, f32, f32), (f32, f32, f32, f32), (f32, f32, f32, f32)
 }
 
 impl Frame for [[f32; 4]; 4] {
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect) {
+  fn draw(&self, draw: &mut PaintList, rect: Rect) {
     FillColor::from(*self).draw(draw, rect)
   }
   fn covers_opaque(&self) -> bool {
@@ -86,7 +87,7 @@ impl Frame for [[f32; 4]; 4] {
 // Corners (RGB):
 
 impl Frame for Corners<Vec3> {
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect) {
+  fn draw(&self, draw: &mut PaintList, rect: Rect) {
     FillColor::from(*self).draw(draw, rect)
   }
   fn covers_opaque(&self) -> bool {
@@ -95,7 +96,7 @@ impl Frame for Corners<Vec3> {
 }
 
 impl Frame for (Vec3, Vec3, Vec3, Vec3) {
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect) {
+  fn draw(&self, draw: &mut PaintList, rect: Rect) {
     FillColor::from(*self).draw(draw, rect)
   }
   fn covers_opaque(&self) -> bool {
@@ -104,7 +105,7 @@ impl Frame for (Vec3, Vec3, Vec3, Vec3) {
 }
 
 impl Frame for ((f32, f32, f32), (f32, f32, f32), (f32, f32, f32), (f32, f32, f32)) {
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect) {
+  fn draw(&self, draw: &mut PaintList, rect: Rect) {
     FillColor::from(*self).draw(draw, rect)
   }
   fn covers_opaque(&self) -> bool {
@@ -113,7 +114,7 @@ impl Frame for ((f32, f32, f32), (f32, f32, f32), (f32, f32, f32), (f32, f32, f3
 }
 
 impl Frame for [[f32; 3]; 4] {
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect) {
+  fn draw(&self, draw: &mut PaintList, rect: Rect) {
     FillColor::from(*self).draw(draw, rect)
   }
   fn covers_opaque(&self) -> bool {
@@ -124,7 +125,7 @@ impl Frame for [[f32; 3]; 4] {
 // RGBA:
 
 impl Frame for Vec4 {
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect) {
+  fn draw(&self, draw: &mut PaintList, rect: Rect) {
     FillColor::from(*self).draw(draw, rect)
   }
   fn covers_opaque(&self) -> bool {
@@ -133,7 +134,7 @@ impl Frame for Vec4 {
 }
 
 impl Frame for (f32, f32, f32, f32) {
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect) {
+  fn draw(&self, draw: &mut PaintList, rect: Rect) {
     FillColor::from(*self).draw(draw, rect)
   }
   fn covers_opaque(&self) -> bool {
@@ -142,7 +143,7 @@ impl Frame for (f32, f32, f32, f32) {
 }
 
 impl Frame for [f32; 4] {
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect) {
+  fn draw(&self, draw: &mut PaintList, rect: Rect) {
     FillColor::from(*self).draw(draw, rect)
   }
   fn covers_opaque(&self) -> bool {
@@ -153,7 +154,7 @@ impl Frame for [f32; 4] {
 // RGB:
 
 impl Frame for Vec3 {
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect) {
+  fn draw(&self, draw: &mut PaintList, rect: Rect) {
     FillColor::from(*self).draw(draw, rect)
   }
   fn covers_opaque(&self) -> bool {
@@ -162,7 +163,7 @@ impl Frame for Vec3 {
 }
 
 impl Frame for (f32, f32, f32) {
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect) {
+  fn draw(&self, draw: &mut PaintList, rect: Rect) {
     FillColor::from(*self).draw(draw, rect)
   }
   fn covers_opaque(&self) -> bool {
@@ -171,7 +172,7 @@ impl Frame for (f32, f32, f32) {
 }
 
 impl Frame for [f32; 3] {
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect) {
+  fn draw(&self, draw: &mut PaintList, rect: Rect) {
     FillColor::from(*self).draw(draw, rect)
   }
   fn covers_opaque(&self) -> bool {
diff --git a/hui/src/frame/nine_patch.rs b/hui/src/frame/nine_patch.rs
index 09a58c1..352b0d1 100644
--- a/hui/src/frame/nine_patch.rs
+++ b/hui/src/frame/nine_patch.rs
@@ -3,10 +3,10 @@
 //! A 9-patch image is an image that can be scaled in a way that preserves the corners and edges of the image while scaling the center.
 //! This is useful for creating scalable UI elements like buttons, windows, etc.
 
-use glam::{vec2, UVec2, Vec2};
+use glam::{vec2, Affine2, UVec2, Vec2};
+use hui_painter::{paint::command::{PaintList, PaintRectangle, PaintTransform}, texture::TextureHandle};
 use crate::{
   color,
-  draw::{ImageHandle, UiDrawCommand, UiDrawCommandList},
   rect::{Rect, Corners, FillColor}
 };
 use super::Frame;
@@ -14,7 +14,7 @@ use super::Frame;
 /// Represents a 9-patch image asset
 #[derive(Clone, Copy, Debug)]
 pub struct NinePatchAsset {
-  pub image: ImageHandle,
+  pub image: TextureHandle,
   //TODO: remove this:
   pub size: (u32, u32),
   pub scalable_region: Rect,
@@ -46,14 +46,14 @@ impl Default for NinePatchFrame {
   fn default() -> Self {
     Self {
       //This is not supposed to be left out as the default, so just set it to whatever :p
-      asset: NinePatchAsset { image: ImageHandle::default(), size: (0, 0), scalable_region: Rect::default() },
+      asset: NinePatchAsset { image: TextureHandle::new_broken(), size: (0, 0), scalable_region: Rect::default() },
       color: color::WHITE.into(),
     }
   }
 }
 
 impl Frame for NinePatchFrame {
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect) {
+  fn draw(&self, draw: &mut PaintList, rect: Rect) {
     // without this, shїt gets messed up when the position is not a whole number
     //XXX: should we round the size as well?
     let position = rect.position.round();
@@ -100,13 +100,15 @@ impl Frame for NinePatchFrame {
       bottom_left: vec2(0., region_uv.top_left.y),
       bottom_right: region_uv.top_left,
     };
-    draw.add(UiDrawCommand::Rectangle {
-      position,
-      size: vec2(size_h.0, size_v.0),
-      color: interpolate_color_rect(top_left_patch_uv),
-      texture: Some(self.asset.image),
-      texture_uv: Some(top_left_patch_uv),
-      rounded_corners: None
+    draw.add(PaintTransform {
+      transform: Affine2::from_translation(position),
+      child: PaintRectangle {
+        size: vec2(size_h.0, size_v.0),
+        color: interpolate_color_rect(top_left_patch_uv).into(),
+        texture: Some(self.asset.image),
+        texture_uv: top_left_patch_uv,
+        ..Default::default()
+      },
     });
 
     //Top patch
@@ -116,13 +118,15 @@ impl Frame for NinePatchFrame {
       bottom_left: region_uv.top_left,
       bottom_right: region_uv.top_right,
     };
-    draw.add(UiDrawCommand::Rectangle {
-      position: position + vec2(size_h.0, 0.),
-      size: vec2(size_h.1, size_v.0),
-      color: interpolate_color_rect(top_patch_uv),
-      texture: Some(self.asset.image),
-      texture_uv: Some(top_patch_uv),
-      rounded_corners: None
+    draw.add(PaintTransform {
+      transform: Affine2::from_translation(position + vec2(size_h.0, 0.)),
+      child: PaintRectangle {
+        size: vec2(size_h.1, size_v.0),
+        color: interpolate_color_rect(top_patch_uv).into(),
+        texture: Some(self.asset.image),
+        texture_uv: top_patch_uv,
+        ..Default::default()
+      },
     });
 
     //Top-right patch
@@ -132,13 +136,15 @@ impl Frame for NinePatchFrame {
       bottom_left: region_uv.top_right,
       bottom_right: vec2(1., region_uv.top_right.y),
     };
-    draw.add(UiDrawCommand::Rectangle {
-      position: position + vec2(size_h.0 + size_h.1, 0.),
-      size: vec2(size_h.2, size_v.0),
-      color: interpolate_color_rect(top_right_patch_uv),
-      texture: Some(self.asset.image),
-      texture_uv: Some(top_right_patch_uv),
-      rounded_corners: None
+    draw.add(PaintTransform {
+      transform: Affine2::from_translation(position + vec2(size_h.0 + size_h.1, 0.)),
+      child: PaintRectangle {
+        size: vec2(size_h.2, size_v.0),
+        color: interpolate_color_rect(top_right_patch_uv).into(),
+        texture: Some(self.asset.image),
+        texture_uv: top_right_patch_uv,
+        ..Default::default()
+      },
     });
 
     //Left patch
@@ -148,23 +154,27 @@ impl Frame for NinePatchFrame {
       bottom_left: vec2(0., region_uv.bottom_left.y),
       bottom_right: region_uv.bottom_left,
     };
-    draw.add(UiDrawCommand::Rectangle {
-      position: position + vec2(0., size_v.0),
-      size: vec2(size_h.0, size_v.1),
-      color: interpolate_color_rect(left_patch_uv),
-      texture: Some(self.asset.image),
-      texture_uv: Some(left_patch_uv),
-      rounded_corners: None
+    draw.add(PaintTransform {
+      transform: Affine2::from_translation(position + vec2(0., size_v.0)),
+      child: PaintRectangle {
+        size: vec2(size_h.0, size_v.1),
+        color: interpolate_color_rect(left_patch_uv).into(),
+        texture: Some(self.asset.image),
+        texture_uv: left_patch_uv,
+        ..Default::default()
+      },
     });
 
     // Center patch
-    draw.add(UiDrawCommand::Rectangle {
-      position: position + vec2(size_h.0, size_v.0),
-      size: vec2(size_h.1, size_v.1),
-      color: interpolate_color_rect(region_uv),
-      texture: Some(self.asset.image),
-      texture_uv: Some(region_uv),
-      rounded_corners: None
+    draw.add(PaintTransform {
+      transform: Affine2::from_translation(position + vec2(size_h.0, size_v.0)),
+      child: PaintRectangle {
+        size: vec2(size_h.1, size_v.1),
+        color: interpolate_color_rect(region_uv).into(),
+        texture: Some(self.asset.image),
+        texture_uv: region_uv,
+        ..Default::default()
+      },
     });
 
     //Right patch
@@ -174,13 +184,15 @@ impl Frame for NinePatchFrame {
       bottom_left: region_uv.bottom_right,
       bottom_right: vec2(1., region_uv.bottom_right.y),
     };
-    draw.add(UiDrawCommand::Rectangle {
-      position: position + vec2(size_h.0 + size_h.1, size_v.0),
-      size: vec2(size_h.2, size_v.1),
-      color: interpolate_color_rect(right_patch_uv),
-      texture: Some(self.asset.image),
-      texture_uv: Some(right_patch_uv),
-      rounded_corners: None
+    draw.add(PaintTransform {
+      transform: Affine2::from_translation(position + vec2(size_h.0 + size_h.1, size_v.0)),
+      child: PaintRectangle {
+        size: vec2(size_h.2, size_v.1),
+        color: interpolate_color_rect(right_patch_uv).into(),
+        texture: Some(self.asset.image),
+        texture_uv: right_patch_uv,
+        ..Default::default()
+      },
     });
 
     //Bottom-left patch
@@ -190,13 +202,15 @@ impl Frame for NinePatchFrame {
       bottom_left: vec2(0., 1.),
       bottom_right: vec2(region_uv.bottom_left.x, 1.),
     };
-    draw.add(UiDrawCommand::Rectangle {
-      position: position + vec2(0., size_v.0 + size_v.1),
-      size: vec2(size_h.0, size_v.2),
-      color: interpolate_color_rect(bottom_left_patch_uv),
-      texture: Some(self.asset.image),
-      texture_uv: Some(bottom_left_patch_uv),
-      rounded_corners: None
+    draw.add(PaintTransform {
+      transform: Affine2::from_translation(position + vec2(0., size_v.0 + size_v.1)),
+      child: PaintRectangle {
+        size: vec2(size_h.0, size_v.2),
+        color: interpolate_color_rect(bottom_left_patch_uv).into(),
+        texture: Some(self.asset.image),
+        texture_uv: bottom_left_patch_uv,
+        ..Default::default()
+      },
     });
 
     //Bottom patch
@@ -206,13 +220,15 @@ impl Frame for NinePatchFrame {
       bottom_left: vec2(region_uv.bottom_left.x, 1.),
       bottom_right: vec2(region_uv.bottom_right.x, 1.),
     };
-    draw.add(UiDrawCommand::Rectangle {
-      position: position + vec2(size_h.0, size_v.0 + size_v.1),
-      size: vec2(size_h.1, size_v.2),
-      color: interpolate_color_rect(bottom_patch_uv),
-      texture: Some(self.asset.image),
-      texture_uv: Some(bottom_patch_uv),
-      rounded_corners: None
+    draw.add(PaintTransform {
+      transform: Affine2::from_translation(position + vec2(size_h.0, size_v.0 + size_v.1)),
+      child: PaintRectangle {
+        size: vec2(size_h.1, size_v.2),
+        color: interpolate_color_rect(bottom_patch_uv).into(),
+        texture: Some(self.asset.image),
+        texture_uv: bottom_patch_uv,
+        ..Default::default()
+      },
     });
 
     //Bottom-right patch
@@ -222,13 +238,15 @@ impl Frame for NinePatchFrame {
       bottom_left: vec2(region_uv.bottom_right.x, 1.),
       bottom_right: vec2(1., 1.),
     };
-    draw.add(UiDrawCommand::Rectangle {
-      position: position + vec2(size_h.0 + size_h.1, size_v.0 + size_v.1),
-      size: vec2(size_h.2, size_v.2),
-      color: interpolate_color_rect(bottom_right_patch_uv),
-      texture: Some(self.asset.image),
-      texture_uv: Some(bottom_right_patch_uv),
-      rounded_corners: None
+    draw.add(PaintTransform {
+      transform: Affine2::from_translation(position + vec2(size_h.0 + size_h.1, size_v.0 + size_v.1)),
+      child: PaintRectangle {
+        size: vec2(size_h.2, size_v.2),
+        color: interpolate_color_rect(bottom_right_patch_uv).into(),
+        texture: Some(self.asset.image),
+        texture_uv: bottom_right_patch_uv,
+        ..Default::default()
+      },
     });
   }
 
diff --git a/hui/src/frame/rect.rs b/hui/src/frame/rect.rs
index d6e5d5e..746876f 100644
--- a/hui/src/frame/rect.rs
+++ b/hui/src/frame/rect.rs
@@ -1,7 +1,7 @@
-use glam::Vec2;
+use glam::{Affine2, Vec2};
+use hui_painter::{paint::command::{PaintList, PaintRectangle, PaintTransform}, texture::TextureHandle};
 use crate::{
   color,
-  draw::{ImageHandle, RoundedCorners, UiDrawCommand, UiDrawCommandList},
   rect::{Rect, Corners, FillColor},
 };
 use super::{Frame, point::FramePoint2d};
@@ -23,7 +23,7 @@ pub struct RectFrame {
   ///
   /// Please note that if the background color is NOT set (or set to transparent), the texture will NOT be visible\
   /// This is because the texture is multiplied by the color, and if the color is transparent, the texture will be too\
-  pub image: Option<ImageHandle>,
+  pub image: Option<TextureHandle>,
 
   /// Top left corner of the rectangle
   pub top_left: FramePoint2d,
@@ -47,8 +47,8 @@ impl From<FillColor> for RectFrame {
   }
 }
 
-impl From<ImageHandle> for RectFrame {
-  fn from(image: ImageHandle) -> Self {
+impl From<TextureHandle> for RectFrame {
+  fn from(image: TextureHandle) -> Self {
     Self::image(image)
   }
 }
@@ -65,7 +65,7 @@ impl RectFrame {
   /// Create a new [`RectFrame`] with the given image\
   ///
   /// Color will be set to [`WHITE`](crate::color::WHITE) to ensure the image is visible
-  pub fn image(image: ImageHandle) -> Self {
+  pub fn image(image: TextureHandle) -> Self {
     Self {
       color: color::WHITE.into(),
       image: Some(image),
@@ -74,7 +74,7 @@ impl RectFrame {
   }
 
   /// Create a new [`RectFrame`] with the given color and image
-  pub fn color_image(color: impl Into<FillColor>, image: ImageHandle) -> Self {
+  pub fn color_image(color: impl Into<FillColor>, image: TextureHandle) -> Self {
     Self {
       color: color.into(),
       image: Some(image),
@@ -115,22 +115,21 @@ impl Default for RectFrame {
 }
 
 impl Frame for RectFrame {
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect) {
+  fn draw(&self, draw: &mut PaintList, rect: Rect) {
     if self.color.is_transparent() {
       return
     }
     //TODO: handle bottom_right < top_left
     let top_left = self.top_left.resolve(rect.size);
     let bottom_right = self.bottom_right.resolve(rect.size);
-    draw.add(UiDrawCommand::Rectangle {
-      position: rect.position + top_left,
-      size: bottom_right - top_left,
-      color: self.color.corners(),
-      texture: self.image,
-      texture_uv: None,
-      rounded_corners: (self.corner_radius.max_f32() > 0.).then_some(
-        RoundedCorners::from_radius(self.corner_radius)
-      ),
+    draw.add(PaintTransform{
+      transform: Affine2::from_translation(rect.position + top_left),
+      child: PaintRectangle {
+        size: bottom_right - top_left,
+        color: self.color,
+        texture: self.image,
+        ..Default::default()
+      },
     });
   }
 
diff --git a/hui/src/frame/stack.rs b/hui/src/frame/stack.rs
index a2dfd5d..fd47f79 100644
--- a/hui/src/frame/stack.rs
+++ b/hui/src/frame/stack.rs
@@ -1,13 +1,14 @@
 //! allows stacking two frames on top of each other
 
-use crate::{draw::UiDrawCommandList, rect::Rect};
+use hui_painter::paint::command::PaintList;
+use crate::rect::Rect;
 use super::Frame;
 
 /// A frame that draws two frames on top of each other
 pub struct FrameStack(pub Box<dyn Frame>, pub Box<dyn Frame>);
 
 impl Frame for FrameStack {
-  fn draw(&self, draw: &mut UiDrawCommandList, rect: Rect) {
+  fn draw(&self, draw: &mut PaintList, rect: Rect) {
     self.0.draw(draw, rect);
     self.1.draw(draw, rect);
   }
diff --git a/hui/src/instance.rs b/hui/src/instance.rs
index 6050e5f..b098556 100644
--- a/hui/src/instance.rs
+++ b/hui/src/instance.rs
@@ -1,18 +1,17 @@
+use hui_painter::{
+  paint::{buffer::PaintBuffer, command::{PaintCommand, PaintList, PaintRoot}},
+  text::FontHandle,
+  texture::{SourceTextureFormat, TextureHandle},
+  PainterInstance,
+};
 use crate::{
   element::{MeasureContext, ProcessContext, UiElement},
-  layout::{Direction, LayoutInfo},
-  text::{FontHandle, TextRenderer},
-  draw::{
-    ImageHandle,
-    TextureFormat,
-    UiDrawCall,
-    UiDrawCommandList,
-    atlas::{TextureAtlasManager, TextureAtlasMeta},
-  },
-  signal::{Signal, SignalStore},
   event::{EventQueue, UiEvent},
+  font::FontStack,
   input::UiInputState,
+  layout::{Direction, LayoutInfo},
   rect::Rect,
+  signal::{Signal, SignalStore},
   state::StateRepo,
 };
 
@@ -21,16 +20,16 @@ use crate::{
 /// In most cases, you should only have one instance of this struct, but multiple instances are allowed\
 /// (Please note that it's possible to render multiple UI "roots" using a single instance)
 pub struct UiInstance {
+  painter: PainterInstance,
+  prev_draw_command_hash: Option<u64>,
+  cur_draw_command_hash: Option<u64>,
+  draw_commands: PaintList,
+  paint_buffer: PaintBuffer,
   stateful_state: StateRepo,
-  prev_draw_commands: UiDrawCommandList,
-  draw_commands: UiDrawCommandList,
-  draw_call: UiDrawCall,
-  draw_call_modified: bool,
-  text_renderer: TextRenderer,
-  atlas: TextureAtlasManager,
   events: EventQueue,
   input: UiInputState,
   signal: SignalStore,
+  font_stack: FontStack,
   /// True if in the middle of a laying out a frame
   state: bool,
 }
@@ -41,21 +40,13 @@ impl UiInstance {
   /// In most cases, you should only do this *once*, during the initialization of your application
   pub fn new() -> Self {
     UiInstance {
-      //mouse_position: Vec2::ZERO,
+      painter: PainterInstance::new(),
+      prev_draw_command_hash: None,
+      cur_draw_command_hash: None,
+      draw_commands: PaintList::default(),
+      paint_buffer: PaintBuffer::new(),
+      font_stack: FontStack::new(),
       stateful_state: StateRepo::new(),
-      //event_queue: VecDeque::new(),
-      // root_elements: Vec::new(),
-      prev_draw_commands: UiDrawCommandList::default(),
-      draw_commands: UiDrawCommandList::default(),
-      draw_call: UiDrawCall::default(),
-      draw_call_modified: false,
-      // ftm: FontTextureManager::default(),
-      text_renderer: TextRenderer::new(),
-      atlas: {
-        let mut atlas = TextureAtlasManager::default();
-        atlas.add_dummy();
-        atlas
-      },
       events: EventQueue::new(),
       input: UiInputState::new(),
       signal: SignalStore::new(),
@@ -70,8 +61,9 @@ impl UiInstance {
   ///
   /// ## Panics:
   /// If the font data is invalid or corrupt
+  #[deprecated(note = "use painter.fonts_mut().add instead")]
   pub fn add_font(&mut self, font: &[u8]) -> FontHandle {
-    self.text_renderer.add_font_from_bytes(font)
+    self.painter.fonts_mut().add(font)
   }
 
   /// Add an image to the texture atlas\
@@ -80,8 +72,10 @@ impl UiInstance {
   /// Returns an image handle ([`ImageHandle`])\
   /// This handle can be used to reference the texture in draw commands\
   /// It's a light reference and can be cloned/copied freely, but will not be cleaned up even when dropped
-  pub fn add_image(&mut self, format: TextureFormat, data: &[u8], width: usize) -> ImageHandle {
-    self.atlas.add(width, data, format)
+  #[deprecated(note = "use painter.textures_mut().atlas_mut().allocate_with_data instead")]
+  pub fn add_image(&mut self, format: SourceTextureFormat, data: &[u8], width: usize) -> TextureHandle {
+    // self.atlas().add(width, data, format)
+    self.painter.textures_mut().allocate_with_data(format, data, width)
   }
 
   //TODO better error handling
@@ -95,7 +89,8 @@ impl UiInstance {
   /// - If the file exists but contains invalid image data\
   ///   (this will change to a soft error in the future)
   #[cfg(feature = "image")]
-  pub fn add_image_file_path(&mut self, path: impl AsRef<std::path::Path>) -> Result<ImageHandle, std::io::Error> {
+  #[deprecated]
+  pub fn add_image_file_path(&mut self, path: impl AsRef<std::path::Path>) -> Result<TextureHandle, std::io::Error> {
     use std::io::{Read, Seek};
 
     // Open the file (and wrap it in a bufreader)
@@ -115,7 +110,7 @@ impl UiInstance {
 
     //Add the image to the atlas
     let handle = self.add_image(
-      TextureFormat::Rgba,
+      SourceTextureFormat::RGBA8,
       image_rgba,
       image.width() as usize
     );
@@ -129,7 +124,7 @@ impl UiInstance {
   /// This function is useful for replacing the default font, use sparingly\
   /// (This library attempts to be stateless, however passing the font to every text element is not very practical)
   pub fn push_font(&mut self, font: FontHandle) {
-    self.text_renderer.push_font(font);
+    self.font_stack.push(font);
   }
 
   /// Pop a font from the font stack\
@@ -137,12 +132,12 @@ impl UiInstance {
   /// ## Panics:
   /// If the font stack is empty
   pub fn pop_font(&mut self) {
-    self.text_renderer.pop_font();
+    self.font_stack.pop();
   }
 
   /// Get the current default font
-  pub fn current_font(&self) -> FontHandle {
-    self.text_renderer.current_font()
+  pub fn current_font(&self) -> Option<FontHandle> {
+    self.font_stack.current()
   }
 
   /// Add an element or an element tree to the UI
@@ -161,23 +156,23 @@ impl UiInstance {
       direction: Direction::Vertical,
       remaining_space: None,
     };
+    // TODO handle font_stack.current() font being None
+    let current_font = self.font_stack.current().expect("No current font");
     let measure = element.measure(MeasureContext {
+      painter: &self.painter,
       state: &self.stateful_state,
       layout: &layout,
-      text_measure: self.text_renderer.to_measure(),
-      current_font: self.text_renderer.current_font(),
-      images: self.atlas.context(),
+      current_font,
     });
     element.process(ProcessContext {
+      painter: &mut self.painter,
       measure: &measure,
       state: &mut self.stateful_state,
       layout: &layout,
-      draw: &mut self.draw_commands,
-      text_measure: self.text_renderer.to_measure(),
-      current_font: self.text_renderer.current_font(),
-      images: self.atlas.context(),
+      paint_target: &mut self.draw_commands,
       input: self.input.ctx(),
       signal: &mut self.signal,
+      current_font,
     });
   }
 
@@ -198,13 +193,19 @@ impl UiInstance {
     //then, reset the (remaining) signals
     self.signal.clear();
 
+    // Compute the hash of the current commands
+    self.prev_draw_command_hash = Some(self.draw_commands.cache_hash());
+
+    // Clear the draw commands
+    self.draw_commands.clear();
+
     //then, reset the draw commands
-    std::mem::swap(&mut self.prev_draw_commands, &mut self.draw_commands);
-    self.draw_commands.commands.clear();
-    self.draw_call_modified = false;
+    // std::mem::swap(&mut self.prev_draw_commands, &mut self.draw_commands);
+    // self.draw_commands.commands.clear();
+    // self.draw_call_modified = false;
 
     //reset atlas modification flag
-    self.atlas.reset_modified();
+    // self.atlas.reset_modified();
   }
 
   /// End the frame and prepare the UI for rendering\
@@ -219,13 +220,17 @@ impl UiInstance {
     self.state = false;
 
     //check if the draw commands have been modified
-    if self.draw_commands.commands == self.prev_draw_commands.commands {
-      return
+    if let Some(prev_hash) = self.prev_draw_command_hash {
+      let cur_hash = self.draw_commands.cache_hash();
+      self.cur_draw_command_hash = Some(cur_hash);
+      if cur_hash == prev_hash {
+        return
+      }
     }
 
     //if they have, rebuild the draw call and set the modified flag
-    self.draw_call = UiDrawCall::build(&self.draw_commands, &mut self.atlas, &mut self.text_renderer);
-    self.draw_call_modified = true;
+    self.paint_buffer.clear();
+    self.draw_commands.paint_root(&mut self.painter, &mut self.paint_buffer);
   }
 
   /// Get the draw call information for the current frame
@@ -233,18 +238,18 @@ impl UiInstance {
   /// This function should only be used by the render backend.\
   /// You should not call this directly unless you're implementing a custom render backend
   ///
-  /// Returns a tuple with a boolean indicating if the buffers have been modified since the last frame
+  /// Returns a tuple with a unique hash of the draw commands and the draw call data\
   ///
   /// You should only call this function *after* [`UiInstance::end`]\
   /// Calling it in the middle of a frame will result in a warning but will not cause a panic\
   /// (please note that doing so is probably a mistake and should be fixed in your code)\
-  /// Doing so anyway will return draw call data for the previous frame, but the `modified` flag will *always* be incorrect until [`UiInstance::end`] is called
+  /// Doing so anyway will return draw call data for the previous frame
   ///
-  pub fn draw_call(&self) -> (bool, &UiDrawCall) {
+  pub fn backend_paint_buffer(&self) -> (u64, &PaintBuffer) {
     if self.state {
       log::warn!("UiInstance::draw_call called while in the middle of a frame, this is probably a mistake");
     }
-    (self.draw_call_modified, &self.draw_call)
+    (self.cur_draw_command_hash.unwrap_or_default(), &self.paint_buffer)
   }
 
   /// Get the texture atlas size and data for the current frame
@@ -260,11 +265,13 @@ impl UiInstance {
   ///
   /// Make sure to check [`TextureAtlasMeta::modified`] to see if the texture has been modified
   /// since the beginning of the current frame before uploading it to the GPU
-  pub fn atlas(&self) -> TextureAtlasMeta {
+  pub fn backend_atlas(&self) -> TextureAtlasMeta {
     if self.state {
       log::warn!("UiInstance::atlas called while in the middle of a frame, this is probably a mistake");
     }
-    self.atlas.meta()
+    // unimplemented!()
+    // self.painter.textures_mut().
+    // self.backend_atlas.meta()
   }
 
   /// Push a platform event to the UI event queue
diff --git a/hui/src/lib.rs b/hui/src/lib.rs
index c5dbff8..0701b7d 100644
--- a/hui/src/lib.rs
+++ b/hui/src/lib.rs
@@ -12,6 +12,7 @@
 #![allow(unused_parens)]
 
 pub use hui_shared::*;
+pub use hui_painter as painter;
 
 mod instance;
 mod macros;
@@ -19,11 +20,10 @@ pub mod layout;
 pub mod element;
 pub mod event;
 pub mod input;
-pub mod draw;
 pub mod measure;
 pub mod state;
-pub mod text;
 pub mod signal;
 pub mod frame;
+pub mod font;
 
 pub use instance::UiInstance;
diff --git a/hui/src/text.rs b/hui/src/text.rs
deleted file mode 100644
index 55760a1..0000000
--- a/hui/src/text.rs
+++ /dev/null
@@ -1,106 +0,0 @@
-//! text rendering, styling, measuring
-
-use std::sync::Arc;
-use fontdue::{Font, FontSettings};
-use crate::draw::atlas::TextureAtlasManager;
-
-mod font;
-mod ftm;
-mod stack;
-
-/// Built-in font handle
-#[cfg(feature="builtin_font")]
-pub use font::BUILTIN_FONT;
-pub use font::FontHandle;
-
-use font::FontManager;
-use ftm::FontTextureManager;
-use ftm::GlyphCacheEntry;
-use stack::FontStack;
-
-pub(crate) struct TextRenderer {
-  manager: FontManager,
-  ftm: FontTextureManager,
-  stack: FontStack,
-}
-
-impl TextRenderer {
-  pub fn new() -> Self {
-    Self {
-      manager: FontManager::new(),
-      ftm: FontTextureManager::default(),
-      stack: FontStack::new(),
-    }
-  }
-
-  pub fn add_font_from_bytes(&mut self, font: &[u8]) -> FontHandle {
-    self.manager.add_font(Font::from_bytes(font, FontSettings::default()).unwrap())
-  }
-
-  pub fn glyph(&mut self, atlas: &mut TextureAtlasManager, font_handle: FontHandle, character: char, size: u8) -> Arc<GlyphCacheEntry> {
-    self.ftm.glyph(atlas, &self.manager, font_handle, character, size)
-  }
-
-  pub fn push_font(&mut self, font: FontHandle) {
-    self.stack.push(font);
-  }
-
-  pub fn pop_font(&mut self) {
-    self.stack.pop();
-  }
-
-  pub fn current_font(&self) -> FontHandle {
-    self.stack.current_or_default()
-  }
-
-  pub(crate) fn internal_font(&self, handle: FontHandle) -> &Font {
-    self.manager.get(handle).unwrap()
-  }
-}
-
-impl Default for TextRenderer {
-  fn default() -> Self {
-    Self::new()
-  }
-}
-
-/// Size of measured text
-pub struct TextMeasureResponse {
-  pub max_width: f32,
-  pub height: f32,
-}
-
-/// Context for measuring text
-#[derive(Clone, Copy)]
-pub struct TextMeasure<'a>(&'a TextRenderer);
-
-impl TextMeasure<'_> {
-  /// Measure the given string of text with the given font and size
-  pub fn measure(&self, font: FontHandle, size: u16, text: &str) -> TextMeasureResponse {
-    use fontdue::layout::{Layout, CoordinateSystem, TextStyle};
-    let mut layout = Layout::new(CoordinateSystem::PositiveYDown);
-    layout.append(
-      &[self.0.internal_font(font)],
-      &TextStyle::new(text, size as f32, 0)
-    );
-    TextMeasureResponse {
-      max_width: layout.lines().map(|lines| {
-        lines.iter().fold(0.0_f32, |acc, x| {
-          let glyph = layout.glyphs().get(x.glyph_end).unwrap();
-          acc.max(glyph.x + glyph.width as f32)
-        })
-      }).unwrap_or(0.),
-      height: layout.height(),
-    }
-  }
-}
-
-impl TextRenderer {
-  pub fn to_measure(&self) -> TextMeasure {
-    TextMeasure(self)
-  }
-
-  pub fn measure(&self, font: FontHandle, size: u16, text: &str) -> TextMeasureResponse {
-    TextMeasure(self).measure(font, size, text)
-  }
-}
diff --git a/hui/src/text/font.rs b/hui/src/text/font.rs
deleted file mode 100644
index e9e89e5..0000000
--- a/hui/src/text/font.rs
+++ /dev/null
@@ -1,65 +0,0 @@
-use fontdue::Font;
-
-/// Font handle, stores the internal font id and can be cheaply copied.
-///
-/// Only valid for the `UiInstance` that created it.\
-/// Using it with other instances may result in panics or unexpected behavior.
-///
-/// Handle values are not guaranteed to be valid.\
-/// Creating or transmuting an invalid handle is allowed and is *not* UB.
-///
-/// Internal value is an implementation detail and should not be relied upon.
-#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
-pub struct FontHandle(pub(crate) usize);
-
-#[cfg(feature = "builtin_font")]
-pub const BUILTIN_FONT: FontHandle = FontHandle(0);
-
-impl Default for FontHandle {
-  /// Default font handle is the builtin font, if the feature is enabled;\
-  /// Otherwise returns an invalid handle.
-  fn default() -> Self {
-    #[cfg(feature = "builtin_font")] { BUILTIN_FONT }
-    #[cfg(not(feature = "builtin_font"))] { Self(usize::MAX) }
-  }
-}
-
-#[cfg(feature = "builtin_font")]
-const BUILTIN_FONT_DATA: &[u8] = include_bytes!("../../assets/font/ProggyTiny.ttf");
-
-pub struct FontManager {
-  fonts: Vec<Font>,
-}
-
-impl FontManager {
-  pub fn new() -> Self {
-    let mut this = Self {
-      fonts: Vec::new(),
-    };
-    #[cfg(feature = "builtin_font")]
-    {
-      let font = Font::from_bytes(
-        BUILTIN_FONT_DATA,
-        fontdue::FontSettings::default()
-      ).unwrap();
-      this.add_font(font);
-    };
-    this
-  }
-
-  /// Add a (fontdue) font to the renderer.
-  pub fn add_font(&mut self, font: Font) -> FontHandle {
-    self.fonts.push(font);
-    FontHandle(self.fonts.len() - 1)
-  }
-
-  pub fn get(&self, handle: FontHandle) -> Option<&Font> {
-    self.fonts.get(handle.0)
-  }
-}
-
-impl Default for FontManager {
-  fn default() -> Self {
-    Self::new()
-  }
-}
diff --git a/hui/src/text/ftm.rs b/hui/src/text/ftm.rs
deleted file mode 100644
index 348c982..0000000
--- a/hui/src/text/ftm.rs
+++ /dev/null
@@ -1,74 +0,0 @@
-use std::sync::Arc;
-use fontdue::Metrics;
-use hashbrown::HashMap;
-use crate::draw::atlas::{TextureAtlasManager, ImageHandle};
-
-use super::font::{FontHandle, FontManager};
-
-#[derive(PartialEq, Eq, Hash)]
-struct GlyphCacheKey {
-  font_index: usize,
-  character: char,
-  size: u8,
-}
-
-pub struct GlyphCacheEntry {
-  pub metrics: Metrics,
-  pub texture: ImageHandle,
-}
-
-pub struct FontTextureManager {
-  glyph_cache: HashMap<GlyphCacheKey, Arc<GlyphCacheEntry>>
-}
-
-impl FontTextureManager {
-  pub fn new() -> Self {
-    FontTextureManager {
-      glyph_cache: HashMap::new(),
-    }
-  }
-
-  /// Either looks up the glyph in the cache or renders it and adds it to the cache.
-  pub fn glyph(
-    &mut self,
-    atlas: &mut TextureAtlasManager,
-    font_manager: &FontManager,
-    font_handle: FontHandle,
-    character: char,
-    size: u8
-  ) -> Arc<GlyphCacheEntry> {
-    let key = GlyphCacheKey {
-      font_index: font_handle.0,
-      character,
-      size,
-    };
-    if let Some(entry) = self.glyph_cache.get(&key) {
-      return Arc::clone(entry);
-    }
-    let font = font_manager.get(font_handle).unwrap();
-    let (metrics, bitmap) = font.rasterize(character, size as f32);
-    log::trace!("rasterized glyph: {}, {:?}, {:?}", character, metrics, bitmap);
-    let texture = atlas.add_grayscale(metrics.width, &bitmap);
-    let entry = Arc::new(GlyphCacheEntry {
-      metrics,
-      texture
-    });
-    unsafe {
-      self.glyph_cache.insert_unique_unchecked(key, Arc::clone(&entry));
-    }
-    entry
-  }
-
-  // pub fn glyph(&mut self, font_manager: &FontManager, font_handle: FontHandle, character: char, size: u8) -> Arc<GlyphCacheEntry> {
-  //   let (is_new, glyph) = self.glyph_allocate(font_manager, font_handle, character, size);
-  //   if is_new {
-  //     self.glyph_place(&glyph);
-  //     self.modified = true;
-  //   }
-  //   glyph
-  // }
-}
-
-impl Default for FontTextureManager {
-  fn default() -> Self { Self::new() }
-}