kitgit

tirbofish/dropbear · diff

ed895ce · Thribhu K

feature: kino-ui works

Unverified

diff --git a/crates/dropbear-engine/src/graphics.rs b/crates/dropbear-engine/src/graphics.rs
index 5c18635..e53d544 100644
--- a/crates/dropbear-engine/src/graphics.rs
+++ b/crates/dropbear-engine/src/graphics.rs
@@ -197,6 +197,7 @@ impl InstanceRaw {
 
 /// A wrapper to the [wgpu::CommandEncoder]
 pub struct CommandEncoder {
+    queue: Arc<Queue>,
     inner: wgpu::CommandEncoder,
 }
 
@@ -218,13 +219,14 @@ impl CommandEncoder {
     /// Creates a new instance of a command encoder. 
     pub fn new(graphics: Arc<SharedGraphicsContext>, label: Option<&str>) -> Self {
         Self {
+            queue: graphics.queue.clone(),
             inner: graphics.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label }),
         }
     }
-    
-    /// Submits the command encoder for execution. 
-    /// 
-    /// Panics if an unwinding error is caught, or just returns the error as normal. 
+
+    /// Submits the command encoder for execution.
+    ///
+    /// Panics if an unwinding error is caught, or just returns the error as normal.
     pub fn submit(self, graphics: Arc<SharedGraphicsContext>) -> anyhow::Result<()> {
         let command_buffer = self.inner.finish();
 
diff --git a/crates/eucalyptus-editor/Cargo.toml b/crates/eucalyptus-editor/Cargo.toml
index 6c2eafb..19105ae 100644
--- a/crates/eucalyptus-editor/Cargo.toml
+++ b/crates/eucalyptus-editor/Cargo.toml
@@ -11,6 +11,7 @@ readme = "README.md"
 eucalyptus-core = { path = "../eucalyptus-core", features = ["editor"] }
 magna-carta = { path = "../magna-carta" }
 redback-runtime = { path = "../redback-runtime", features = ["debug"]}
+kino-ui = { path = "../kino-ui" }
 
 anyhow.workspace = true
 app_dirs2.workspace = true
@@ -54,7 +55,6 @@ rfd.workspace = true
 semver.workspace = true
 serde.workspace = true
 
-
 [target.'cfg(unix)'.dependencies]
 daemonize = "0.5.0"
 
diff --git a/crates/eucalyptus-editor/src/main.rs b/crates/eucalyptus-editor/src/main.rs
index 35da56f..5a8ee55 100644
--- a/crates/eucalyptus-editor/src/main.rs
+++ b/crates/eucalyptus-editor/src/main.rs
@@ -101,6 +101,7 @@ async fn main() -> anyhow::Result<()> {
             )
             .filter(Some("eucalyptus_core"), LevelFilter::Debug)
             .filter(Some("dropbear_traits"), LevelFilter::Debug)
+            .filter(Some("kino_ui"), LevelFilter::Trace)
             .init();
         log::info!("Initialised logger");
     }
diff --git a/crates/kino-ui/Cargo.toml b/crates/kino-ui/Cargo.toml
new file mode 100644
index 0000000..5bf8cd9
--- /dev/null
+++ b/crates/kino-ui/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "kino-ui"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+readme.workspace = true
+authors.workspace = true
+
+[dependencies]
+wgpu.workspace = true
+parking_lot.workspace = true
+glam.workspace = true
+bytemuck.workspace = true
+image.workspace = true
+anyhow.workspace = true
+log.workspace = true
diff --git a/crates/kino-ui/src/asset.rs b/crates/kino-ui/src/asset.rs
new file mode 100644
index 0000000..c92ee70
--- /dev/null
+++ b/crates/kino-ui/src/asset.rs
@@ -0,0 +1,51 @@
+use std::collections::HashMap;
+use std::marker::PhantomData;
+use crate::rendering::texture::Texture;
+
+/// A handle with type [`T`] that provides an index to the [AssetServer] contents.
+#[derive(Clone, Copy, Hash, Eq, PartialEq, Debug)]
+pub struct Handle<T> {
+    pub id: u64,
+    _phanton: PhantomData<T>
+}
+
+impl<T> Handle<T> {
+    pub fn new(id: u64) -> Self {
+        Self { id, _phanton: Default::default() }
+    }
+}
+
+/// Serves assets as a [Handle], allowing you to reference any object.
+#[derive(Default)]
+pub struct AssetServer {
+    textures: HashMap<u64, Texture>,
+}
+
+impl AssetServer {
+    /// Creates a new instance of an AssetServer.
+    pub fn new() -> AssetServer {
+        AssetServer {
+            textures: Default::default(),
+        }
+    }
+
+    /// Adds a texture and returns a handle.
+    ///
+    /// This assumes a [Texture] has already been created by you. To create a new texture,
+    /// you can use [`Texture::from_bytes`].
+    pub fn add_texture(&mut self, texture: Texture) -> Handle<Texture> {
+        let handle = Handle::new(texture.hash);
+        self.textures.insert(handle.id, texture);
+        handle
+    }
+
+    /// Updates the asset server by inserting the texture provided at the location of the handle,
+    /// and removing the old texture (by returning it back to you).
+    pub fn update_texture(&mut self, handle: Handle<Texture>, texture: Texture) -> Option<Texture> {
+        self.textures.insert(handle.id, texture)
+    }
+
+    pub fn get_texture(&mut self, handle: Handle<Texture>) -> Option<&Texture> {
+        self.textures.get(&handle.id)
+    }
+}
\ No newline at end of file
diff --git a/crates/kino-ui/src/camera.rs b/crates/kino-ui/src/camera.rs
new file mode 100644
index 0000000..9c6d083
--- /dev/null
+++ b/crates/kino-ui/src/camera.rs
@@ -0,0 +1,74 @@
+use bytemuck::{Pod, Zeroable};
+use glam::{Mat4, Vec2};
+use crate::math::Rect;
+
+pub struct Camera2D {
+    position: Vec2,
+    zoom: f32,
+}
+
+impl Default for Camera2D {
+    fn default() -> Self {
+        Self {
+            position: Vec2::ZERO,
+            zoom: 1.0,
+        }
+    }
+}
+
+impl Camera2D {
+    /// Returns the orthographic view-projection matrix for the current camera state
+    pub(crate) fn view_proj(&self, screen_size: Vec2) -> Mat4 {
+        let width = screen_size.x / self.zoom;
+        let height = screen_size.y / self.zoom;
+
+        let left = self.position.x;
+        let right = self.position.x + width;
+        let top = self.position.y;
+        let bottom = self.position.y + height;
+
+        Mat4::orthographic_lh(left, right, bottom, top, -1.0, 1.0)
+    }
+
+    /// Set the camera's position (top-left corner of view)
+    pub fn target(&mut self, position: Vec2) {
+        self.position = position;
+    }
+
+    /// Center the camera on a position
+    pub fn center(&mut self, position: Vec2, screen_size: Vec2) {
+        self.position = position - screen_size / (2.0 * self.zoom);
+    }
+
+    /// Set zoom level, clamped between 0.1 & 10.0 to avoid insanity
+    pub fn set_zoom(&mut self, zoom: f32) {
+        self.zoom = zoom.clamp(0.1, 10.0);
+    }
+
+    /// Returns the viewport rectangle in world coordinates, factoring in zoom
+    /// Useful for culling or visibility checks
+    pub fn viewport(&self, screen_size: Vec2) -> Rect {
+        let size = screen_size / self.zoom;
+        Rect::new(self.position, size)
+    }
+    /// Converts a point from world space to screen space (pixels)
+    pub fn world_to_screen(&self, world: Vec2) -> Vec2 {
+        (world - self.position) * self.zoom
+    }
+
+    /// Converts a point from screen space back to world space
+    pub fn screen_to_world(&self, screen: Vec2) -> Vec2 {
+        screen / self.zoom + self.position
+    }
+}
+
+pub struct CameraRendering {
+    pub buffer: wgpu::Buffer,
+    pub bind_group: wgpu::BindGroup,
+}
+
+#[repr(C)]
+#[derive(Copy, Clone, Pod, Zeroable)]
+pub struct CameraUniform {
+    pub view_proj: [[f32; 4]; 4],
+}
\ No newline at end of file
diff --git a/crates/kino-ui/src/lib.rs b/crates/kino-ui/src/lib.rs
new file mode 100644
index 0000000..5f2d531
--- /dev/null
+++ b/crates/kino-ui/src/lib.rs
@@ -0,0 +1,258 @@
+//! kino-ui, a UI library with instruction set UI rendering.
+//!
+//! Uses wgpu for rendering and winit for input management.
+
+pub mod resp;
+pub mod widgets;
+pub mod rendering;
+pub mod camera;
+pub mod asset;
+pub mod math;
+
+use crate::asset::{AssetServer, Handle};
+use crate::camera::Camera2D;
+use crate::rendering::texture::Texture;
+use crate::rendering::vertex::Vertex;
+use crate::rendering::{KinoWGPURenderer, VertexBatch};
+use crate::resp::WidgetResponse;
+use crate::widgets::{ContaineredWidget, NativeWidget};
+use std::collections::{HashMap, VecDeque};
+use std::fmt::Debug;
+use std::hash::{DefaultHasher, Hash, Hasher};
+use wgpu::{LoadOp, StoreOp};
+
+/// Holds the state of all the instructions, and the vertices+indices for rendering as well
+/// as the responses.
+pub struct KinoState {
+    renderer: KinoWGPURenderer,
+    instruction_set: VecDeque<UiInstructionType>,
+    widget_states: HashMap<WidgetId, WidgetResponse>,
+    assets: AssetServer,
+    batch: PrimitiveBatch,
+    camera: Camera2D,
+}
+
+impl KinoState {
+    /// Creates a new instance of a [KinoState].
+    ///
+    /// This sits inside your `init()` function.
+    pub fn new(renderer: KinoWGPURenderer) -> KinoState {
+        log::debug!("Created KinoState");
+        KinoState {
+            renderer,
+            instruction_set: Default::default(),
+            widget_states: Default::default(),
+            assets: Default::default(),
+            batch: Default::default(),
+            camera: Camera2D::default(),
+        }
+    }
+
+    pub fn add_widget(&mut self, widget: Box<dyn NativeWidget>) {
+        self.instruction_set.push_back(UiInstructionType::Widget(widget));
+    }
+
+    pub fn add_container(&mut self, _container: Box<dyn ContaineredWidget>) {
+        todo!("This is broken rn and idk how to implement it")
+        // self.instruction_set.push_back(UiInstructionType::Containered(
+        //     ContaineredWidgetType::Start {
+        //         id: container.id(),
+        //         widget: container,
+        //     }
+        // ))
+    }
+
+    pub fn add_instruction(&mut self, ui_instruction_type: UiInstructionType) {
+        self.instruction_set.push_back(ui_instruction_type);
+    }
+
+    /// Polls for changes, builds the tree and prepares them for rendering.
+    ///
+    /// This sits inside your `update()` loop.
+    pub fn poll(&mut self) {
+        log::trace!("polling kinostate");
+        let current_instructions = {
+            self.instruction_set.drain(..).collect::<Vec<_>>()
+        };
+
+        self.widget_states.clear();
+
+        let tree = Self::build_tree(current_instructions);
+
+        self.render_tree(tree);
+    }
+
+    /// Pushes the vertices and indices to the renderer.
+    ///
+    /// This sits inside your `render()` loop.
+    pub fn render(
+        &mut self,
+        device: &wgpu::Device,
+        queue: &wgpu::Queue,
+        encoder: &mut wgpu::CommandEncoder,
+        view: &wgpu::TextureView,
+    ) {
+        log::trace!("rendering kinostate");
+        self.renderer.upload_camera_matrix(
+            queue,
+            self.camera
+                .view_proj(self.renderer.size)
+                .to_cols_array_2d(),
+        );
+        let batch = self.batch.take();
+
+        {
+            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+                label: Some("kino render pass"),
+                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+                    view,
+                    depth_slice: None,
+                    resolve_target: None,
+                    ops: wgpu::Operations {
+                        load: LoadOp::Load,
+                        store: StoreOp::Store,
+                    },
+                })],
+                depth_stencil_attachment: None,
+                timestamp_writes: None,
+                occlusion_query_set: None,
+            });
+
+            for mut tg in batch {
+                log::debug!("Rendering textured geometry: {:?}", tg);
+                let texture = tg.texture_id.and_then(|v| {
+                    self.assets.get_texture(v)
+                });
+                self.renderer.draw_batch(&mut pass, device, queue, &mut tg.batch, texture);
+            }
+        }
+    }
+
+    fn build_tree(instructions: Vec<UiInstructionType>) -> Vec<UiNode> {
+        let mut stack: Vec<UiNode> = Vec::new();
+        let mut root = Vec::new();
+
+        for instruction in instructions {
+            match &instruction {
+                UiInstructionType::Containered(container_ty) => {
+                    match container_ty {
+                        ContaineredWidgetType::Start { .. } => {
+                            stack.push(UiNode {
+                                instruction,
+                                children: Vec::new(),
+                            });
+                        }
+                        ContaineredWidgetType::End { .. } => {
+                            if let Some(node) = stack.pop() {
+                                if let Some(parent) = stack.last_mut() {
+                                    parent.children.push(node);
+                                } else {
+                                    root.push(node);
+                                }
+                            }
+                        }
+                    }
+                }
+                UiInstructionType::Widget(_) => {
+                    let node = UiNode {
+                        instruction,
+                        children: Vec::new(),
+                    };
+
+                    if let Some(parent) = stack.last_mut() {
+                        parent.children.push(node);
+                    } else {
+                        root.push(node);
+                    }
+                }
+            }
+        }
+
+        root
+    }
+
+    fn render_tree(&mut self, nodes: Vec<UiNode>) {
+        for node in nodes {
+            match node.instruction {
+                UiInstructionType::Containered(container_ty) => {
+                    match container_ty {
+                        ContaineredWidgetType::Start { widget, .. } => {
+                            log::trace!("Rendering containered widget START");
+                            widget.render(node.children, self);
+                        }
+                        ContaineredWidgetType::End { .. } => {
+                            log::trace!("Rendering end widget END");
+                            // already handled in tree building
+                        }
+                    }
+                }
+                UiInstructionType::Widget(widget) => {
+                    log::trace!("Rendering widget: {:?}", widget.id());
+                    widget.render(self);
+                }
+            }
+        }
+    }
+}
+
+#[derive(Clone, Copy, Hash, Eq, PartialEq, Debug)]
+pub struct WidgetId(pub u64);
+
+impl Into<WidgetId> for &str {
+    fn into(self) -> WidgetId {
+        let mut hasher = DefaultHasher::new();
+        self.hash(&mut hasher);
+        WidgetId(hasher.finish())
+    }
+}
+
+pub enum UiInstructionType {
+    Containered(ContaineredWidgetType),
+    Widget(Box<dyn NativeWidget>),
+}
+
+pub enum ContaineredWidgetType {
+    Start {
+        id: WidgetId,
+        widget: Box<dyn ContaineredWidget>,
+    },
+    End {
+        id: WidgetId,
+    }
+}
+
+pub struct UiNode {
+    pub instruction: UiInstructionType,
+    pub children: Vec<UiNode>,
+}
+
+#[derive(Debug, Default)]
+pub struct TexturedGeometry {
+    pub texture_id: Option<Handle<Texture>>,
+    pub batch: VertexBatch,
+}
+
+#[derive(Default)]
+pub struct PrimitiveBatch {
+    geometry: Vec<TexturedGeometry>,
+}
+
+impl PrimitiveBatch {
+    /// Add verts & indices to batch, preserving submission order & batching consecutive geometry per texture
+    pub fn push(&mut self, verts: &[Vertex], indices: &[u16], texture_id: Option<Handle<Texture>>) {
+        if let Some(tg) = self.geometry.last_mut()
+            && tg.texture_id == texture_id
+        {
+            tg.batch.push(verts, indices);
+            return;
+        }
+
+        let mut batch = VertexBatch::default();
+        batch.push(verts, indices);
+        self.geometry.push(TexturedGeometry { texture_id, batch });
+    }
+
+    pub(crate) fn take(&mut self) -> Vec<TexturedGeometry> {
+        std::mem::take(&mut self.geometry)
+    }
+}
\ No newline at end of file
diff --git a/crates/kino-ui/src/math.rs b/crates/kino-ui/src/math.rs
new file mode 100644
index 0000000..83d50b2
--- /dev/null
+++ b/crates/kino-ui/src/math.rs
@@ -0,0 +1,48 @@
+use glam::{vec2, Vec2};
+
+#[derive(Copy, Clone, PartialEq, Debug)]
+pub struct Rect {
+    pub position: Vec2,
+    pub size: Vec2,
+}
+
+impl Rect {
+    /// Create a new rectangle from position (top-left) & size
+    pub fn new(position: Vec2, size: Vec2) -> Self {
+        Self { position, size }
+    }
+
+    /// Returns the top-left corner (min coords)
+    pub fn min(&self) -> Vec2 {
+        self.position
+    }
+
+    /// Returns the bottom-right corner (max coords)
+    pub fn max(&self) -> Vec2 {
+        self.position + self.size
+    }
+
+    /// Returns the center point of the rectangle
+    pub fn center(&self) -> Vec2 {
+        self.position + self.size * 0.5
+    }
+
+    // Move the rectangle by the given delta vector
+    pub fn translate(&mut self, delta: Vec2) {
+        self.position += delta;
+    }
+
+    /// Returns true if the point is inside of the rectangle
+    pub fn contains(&self, point: Vec2) -> bool {
+        point.cmpge(self.position).all() && point.cmple(self.position + self.size).all()
+    }
+
+    /// Returns the four corners in this order: top-left, top-right, bottom-right, bottom-left
+    pub fn corners(&self) -> [Vec2; 4] {
+        let tl = self.position;
+        let tr = vec2(tl.x + self.size.x, tl.y);
+        let br = vec2(tl.x + self.size.x, tl.y + self.size.y);
+        let bl = vec2(tl.x, tl.y + self.size.y);
+        [tl, tr, br, bl]
+    }
+}
\ No newline at end of file
diff --git a/crates/kino-ui/src/rendering.rs b/crates/kino-ui/src/rendering.rs
new file mode 100644
index 0000000..9c799de
--- /dev/null
+++ b/crates/kino-ui/src/rendering.rs
@@ -0,0 +1,215 @@
+use glam::Vec2;
+use wgpu::{BufferUsages, IndexFormat};
+use wgpu::util::{BufferInitDescriptor, DeviceExt};
+use crate::camera::{CameraRendering, CameraUniform};
+use crate::rendering::pipeline::KinoRendererPipeline;
+use crate::rendering::vertex::Vertex;
+
+pub mod pipeline;
+pub mod texture;
+pub mod vertex;
+
+pub struct KinoWGPURenderer {
+    pipeline: KinoRendererPipeline,
+    default_texture: texture::Texture,
+    pub size: Vec2,
+
+    camera: CameraRendering,
+}
+
+impl KinoWGPURenderer {
+    /// Creates a new `wgpu` renderer for the kino ui system.
+    pub fn new(
+        device: &wgpu::Device,
+        queue: &wgpu::Queue,
+        surface_format: wgpu::TextureFormat,
+        size: [f32; 2],
+    ) -> Self {
+        log::debug!("Creating KinoWGPURenderer");
+        let pipeline = KinoRendererPipeline::new(device, surface_format);
+
+        let camera_buffer = device.create_buffer_init(&BufferInitDescriptor {
+            label: None,
+            contents: bytemuck::bytes_of(&CameraUniform {
+                view_proj: [
+                    [1.0, 0.0, 0.0, 0.0],
+                    [0.0, 1.0, 0.0, 0.0],
+                    [0.0, 0.0, 1.0, 0.0],
+                    [0.0, 0.0, 0.0, 1.0],
+                ],
+            }),
+            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+        });
+
+        let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
+            label: None,
+            layout: &pipeline.camera_bind_group_layout,
+            entries: &[wgpu::BindGroupEntry {
+                binding: 0,
+                resource: camera_buffer.as_entire_binding(),
+            }],
+        });
+
+        let default_texture = texture::Texture::create_default(&device, &queue, &pipeline.texture_bind_group_layout);
+        log::debug!("Created KinoWGPURenderer");
+        Self {
+            pipeline,
+            default_texture,
+            size: Vec2::from_array(size),
+            camera: CameraRendering {
+                buffer: camera_buffer,
+                bind_group: camera_bind_group,
+            },
+        }
+    }
+
+    pub fn upload_camera_matrix(&mut self, queue: &wgpu::Queue, view_proj: [[f32; 4]; 4]) {
+        queue.write_buffer(
+            &self.camera.buffer,
+            0,
+            bytemuck::bytes_of(&CameraUniform { view_proj }),
+        );
+    }
+
+    pub fn draw_batch(
+        &self,
+        r_pass: &mut wgpu::RenderPass<'_>,
+        device: &wgpu::Device,
+        queue: &wgpu::Queue,
+        batch: &mut VertexBatch,
+        texture: Option<&texture::Texture>,
+    ) {
+        if batch.is_empty() {
+            return;
+        }
+        batch.upload(&device, &queue);
+
+        let texture = texture.unwrap_or(&self.default_texture);
+
+        texture.bind(r_pass, 0);
+
+        r_pass.set_pipeline(&self.pipeline.pipeline);
+        r_pass.set_bind_group(1, &self.camera.bind_group, &[]);
+
+        batch.draw(r_pass);
+        batch.clear();
+    }
+}
+
+/// Describes a primitive shape.
+#[derive(Debug)]
+pub struct VertexBatch {
+    vertices: Vec<Vertex>,
+    indices: Vec<u16>,
+    vertex_buffer: Option<wgpu::Buffer>,
+    index_buffer: Option<wgpu::Buffer>,
+    vertices_dirty: bool,
+    indices_dirty: bool,
+}
+
+impl Default for VertexBatch {
+    fn default() -> Self {
+        Self {
+            vertices: Vec::with_capacity(Self::MAX_VERTICES),
+            indices: Vec::with_capacity(Self::MAX_INDICES),
+            vertex_buffer: None,
+            index_buffer: None,
+            vertices_dirty: false,
+            indices_dirty: false,
+        }
+    }
+}
+
+impl VertexBatch {
+    const MAX_VERTICES: usize = u16::MAX as usize;
+    const MAX_INDICES: usize = Self::MAX_VERTICES * 6;
+
+    /// Returns true if adding verts/indices would exceed max allowed
+    fn would_overflow(&self, vert_count: usize, idx_count: usize) -> bool {
+        self.vertices.len() + vert_count > Self::MAX_VERTICES
+            || self.indices.len() + idx_count > Self::MAX_INDICES
+    }
+
+    /// Adds vertices/indices, returns false if it would overflow
+    pub fn push(&mut self, verts: &[Vertex], indices: &[u16]) -> bool {
+        if self.would_overflow(verts.len(), indices.len()) {
+            return false;
+        }
+
+        let idx_offset = self.vertices.len() as u16;
+        self.vertices.extend_from_slice(verts);
+        self.indices.extend(indices.iter().map(|i| *i + idx_offset));
+
+        self.vertices_dirty = true;
+        self.indices_dirty = true;
+
+        true
+    }
+
+    fn upload(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
+        if self.is_empty() || (!self.vertices_dirty && !self.indices_dirty) {
+            return;
+        }
+
+        if self.vertex_buffer.is_none() {
+            self.vertex_buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
+                label: Some("kino vertex buffer"),
+                size: (Self::MAX_VERTICES * std::mem::size_of::<Vertex>()) as u64,
+                usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
+                mapped_at_creation: false,
+            }));
+        }
+        if self.index_buffer.is_none() {
+            self.index_buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
+                label: Some("kino index buffer"),
+                size: (Self::MAX_INDICES * std::mem::size_of::<u16>()) as u64,
+                usage: BufferUsages::INDEX | BufferUsages::COPY_DST,
+                mapped_at_creation: false,
+            }));
+        }
+
+        if self.vertices_dirty {
+            queue.write_buffer(
+                self.vertex_buffer.as_ref().unwrap(),
+                0,
+                bytemuck::cast_slice(&self.vertices),
+            );
+            self.vertices_dirty = false;
+        }
+        if self.indices_dirty {
+            let mut indices_bytes: Vec<u8> = bytemuck::cast_slice(&self.indices).to_vec();
+            let remainder = indices_bytes.len() % wgpu::COPY_BUFFER_ALIGNMENT as usize;
+            if remainder != 0 {
+                let pad_len = wgpu::COPY_BUFFER_ALIGNMENT as usize - remainder;
+                indices_bytes.extend_from_slice(&vec![0u8; pad_len]);
+            }
+
+            queue.write_buffer(self.index_buffer.as_ref().unwrap(), 0, &indices_bytes);
+            self.indices_dirty = false;
+        }
+    }
+
+    fn is_empty(&self) -> bool {
+        self.vertices.is_empty() || self.indices.is_empty()
+    }
+
+    fn clear(&mut self) {
+        self.vertices.clear();
+        self.indices.clear();
+        self.vertices_dirty = true;
+        self.indices_dirty = true;
+    }
+
+    fn draw(&self, pass: &mut wgpu::RenderPass) {
+        if self.is_empty() {
+            return;
+        }
+
+        pass.set_vertex_buffer(0, self.vertex_buffer.as_ref().unwrap().slice(..));
+        pass.set_index_buffer(
+            self.index_buffer.as_ref().unwrap().slice(..),
+            IndexFormat::Uint16,
+        );
+        pass.draw_indexed(0..self.indices.len() as u32, 0, 0..1);
+    }
+}
\ No newline at end of file
diff --git a/crates/kino-ui/src/rendering/pipeline.rs b/crates/kino-ui/src/rendering/pipeline.rs
new file mode 100644
index 0000000..2ade512
--- /dev/null
+++ b/crates/kino-ui/src/rendering/pipeline.rs
@@ -0,0 +1,113 @@
+use crate::rendering::vertex::Vertex;
+
+pub struct KinoRendererPipeline {
+    pub(crate) pipeline: wgpu::RenderPipeline,
+    pub(crate) texture_bind_group_layout: wgpu::BindGroupLayout,
+    pub(crate) camera_bind_group_layout: wgpu::BindGroupLayout,
+}
+
+impl KinoRendererPipeline {
+    pub fn new(
+        device: &wgpu::Device,
+        surface_format: wgpu::TextureFormat,
+    ) -> Self {
+        log::debug!("Initialising pipeline with format {:?}", surface_format);
+        let shader = device.create_shader_module(wgpu::include_wgsl!("../shaders/ui.wgsl"));
+
+        let texture_bind_group_layout = Self::create_texture_bind_group_layout(device);
+        let camera_bind_group_layout = Self::create_camera_bind_group_layout(device);
+
+        let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+            label: Some("kino pipeline layout descriptor"),
+            bind_group_layouts: &[
+                &texture_bind_group_layout,
+                &camera_bind_group_layout,
+            ],
+            push_constant_ranges: &[],
+        });
+
+        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+            label: Some("kino render pipeline"),
+            layout: Some(&layout),
+            vertex: wgpu::VertexState {
+                module: &shader,
+                entry_point: Some("vs_main"),
+                compilation_options: Default::default(),
+                buffers: &[Vertex::desc()],
+            },
+            fragment: Some(wgpu::FragmentState {
+                module: &shader,
+                entry_point: Some("fs_main"),
+                compilation_options: Default::default(),
+                targets: &[Some(wgpu::ColorTargetState {
+                    format: surface_format,
+                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
+                    write_mask: wgpu::ColorWrites::ALL,
+                })],
+            }),
+            primitive: wgpu::PrimitiveState {
+                topology: wgpu::PrimitiveTopology::TriangleList,
+                cull_mode: None,
+                ..Default::default()
+            },
+            depth_stencil: None,
+            multisample: wgpu::MultisampleState::default(),
+            multiview: None,
+            cache: None,
+        });
+        
+        log::debug!("Created pipeline");
+
+        Self {
+            pipeline,
+            texture_bind_group_layout,
+            camera_bind_group_layout,
+        }
+    }
+
+    fn create_texture_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
+        device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+            label: Some("kino texture bind group layout"),
+            entries: &[
+                // texture binding
+                wgpu::BindGroupLayoutEntry {
+                    binding: 0,
+                    visibility: wgpu::ShaderStages::FRAGMENT,
+                    ty: wgpu::BindingType::Texture {
+                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
+                        view_dimension: wgpu::TextureViewDimension::D2,
+                        multisampled: false,
+                    },
+                    count: None,
+                },
+
+                // texture sampler
+                wgpu::BindGroupLayoutEntry {
+                    binding: 1,
+                    visibility: wgpu::ShaderStages::FRAGMENT,
+                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
+                    count: None,
+                },
+            ],
+        })
+    }
+
+    fn create_camera_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
+        device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+            label: Some("kino camera bind group layout"),
+            entries: &[
+                // camera uniform
+                wgpu::BindGroupLayoutEntry {
+                    binding: 0,
+                    visibility: wgpu::ShaderStages::VERTEX,
+                    ty: wgpu::BindingType::Buffer {
+                        ty: wgpu::BufferBindingType::Uniform,
+                        has_dynamic_offset: false,
+                        min_binding_size: None,
+                    },
+                    count: None,
+                }
+            ],
+        })
+    }
+}
\ No newline at end of file
diff --git a/crates/kino-ui/src/rendering/texture.rs b/crates/kino-ui/src/rendering/texture.rs
new file mode 100644
index 0000000..d0884fe
--- /dev/null
+++ b/crates/kino-ui/src/rendering/texture.rs
@@ -0,0 +1,108 @@
+use std::hash::{DefaultHasher, Hash, Hasher};
+use wgpu::{
+    BindGroup, BindGroupDescriptor, BindGroupEntry, BindGroupLayout, BindingResource, Device,
+    Extent3d, Origin3d, Queue, RenderPass, TexelCopyBufferLayout, TexelCopyTextureInfo,
+    TextureAspect, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages,
+};
+
+/// A GPU texture that can be bound in shaders for rendering
+///
+/// Wraps a `wgpu::Texture`, its view, sampler, & bind group
+#[derive(Debug, PartialEq)]
+pub struct Texture {
+    pub(crate) hash: u64,
+    bind_group: BindGroup,
+}
+
+impl Texture {
+    /// Creates a new texture from raw RGBA image data,
+    /// uploads the data, & builds the bind group using the layout
+    ///
+    /// - `data`: Must be in tightly packed 8-bit RGBA format
+    /// - `width`, `height`: Dimensions of the image in pixels
+    pub fn from_bytes(
+        device: &Device,
+        queue: &Queue,
+        bind_group_layout: &BindGroupLayout,
+        data: &[u8],
+        width: u32,
+        height: u32,
+    ) -> Self {
+        log::debug!("Creating new texture");
+        
+        let texture = device.create_texture(&TextureDescriptor {
+            label: None,
+            size: Extent3d {
+                width,
+                height,
+                depth_or_array_layers: 1,
+            },
+            mip_level_count: 1,
+            sample_count: 1,
+            dimension: TextureDimension::D2,
+            format: TextureFormat::Rgba8UnormSrgb,
+            usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
+            view_formats: &[],
+        });
+
+        queue.write_texture(
+            TexelCopyTextureInfo {
+                texture: &texture,
+                mip_level: 0,
+                origin: Origin3d::ZERO,
+                aspect: TextureAspect::All,
+            },
+            data,
+            TexelCopyBufferLayout {
+                offset: 0,
+                bytes_per_row: Some(4 * width),
+                rows_per_image: Some(height),
+            },
+            Extent3d {
+                width,
+                height,
+                depth_or_array_layers: 1,
+            },
+        );
+
+        let view = texture.create_view(&Default::default());
+        let sampler = device.create_sampler(&Default::default());
+        let bind_group = device.create_bind_group(&BindGroupDescriptor {
+            label: None,
+            layout: bind_group_layout,
+            entries: &[
+                BindGroupEntry {
+                    binding: 0,
+                    resource: BindingResource::TextureView(&view),
+                },
+                BindGroupEntry {
+                    binding: 1,
+                    resource: BindingResource::Sampler(&sampler),
+                },
+            ],
+        });
+
+        let mut hasher = DefaultHasher::new();
+        data.hash(&mut hasher);
+        let hash = hasher.finish();
+        
+        log::debug!("Created new texture [{}]", hash);
+        
+        Self { hash, bind_group }
+    }
+
+    /// Creates a 1×1 white fallback texture
+    ///
+    /// Used when no valid texture is provided for a draw call
+    pub fn create_default(device: &Device, queue: &Queue, layout: &BindGroupLayout) -> Self {
+        log::debug!("Creating standard white texture");
+        Self::from_bytes(device, queue, layout, &[255u8, 255, 255, 255], 1, 1)
+    }
+
+    /// Binds this texture at the given index in the render pass
+    ///
+    /// - `index` must match the bind group index used in the pipeline layout
+    pub fn bind(&self, pass: &mut RenderPass, index: u32) {
+        pass.set_bind_group(index, &self.bind_group, &[]);
+    }
+}
\ No newline at end of file
diff --git a/crates/kino-ui/src/rendering/vertex.rs b/crates/kino-ui/src/rendering/vertex.rs
new file mode 100644
index 0000000..25836ca
--- /dev/null
+++ b/crates/kino-ui/src/rendering/vertex.rs
@@ -0,0 +1,48 @@
+use bytemuck::{Pod, Zeroable};
+
+#[repr(C)]
+#[derive(Copy, Clone, Pod, Zeroable, Debug)]
+pub struct Vertex {
+    pub position: [f32; 2],
+    pub colour: [f32; 4],
+    pub tex_coord: [f32; 2],
+}
+
+impl Vertex {
+    pub fn new(position: impl Into<[f32; 2]>, colour: impl Into<[f32; 4]>, tex_coord: impl Into<[f32; 2]>) -> Self {
+        Self {
+            position: position.into(),
+            colour: colour.into(),
+            tex_coord: tex_coord.into(),
+        }
+    }
+    
+    pub fn desc() -> wgpu::VertexBufferLayout<'static> {
+        wgpu::VertexBufferLayout {
+            array_stride: size_of::<Vertex>() as wgpu::BufferAddress,
+            step_mode: wgpu::VertexStepMode::Vertex,
+            attributes: &[
+                // position
+                wgpu::VertexAttribute {
+                    format: wgpu::VertexFormat::Float32x2,
+                    offset: 0,
+                    shader_location: 0,
+                },
+
+                // colour
+                wgpu::VertexAttribute {
+                    format: wgpu::VertexFormat::Float32x4,
+                    offset: size_of::<[f32; 2]>() as wgpu::BufferAddress,
+                    shader_location: 1,
+                },
+
+                // tex_coords
+                wgpu::VertexAttribute {
+                    format: wgpu::VertexFormat::Float32x2,
+                    offset: size_of::<[f32; 6]>() as wgpu::BufferAddress,
+                    shader_location: 2,
+                }
+            ],
+        }
+    }
+}
\ No newline at end of file
diff --git a/crates/kino-ui/src/resp.rs b/crates/kino-ui/src/resp.rs
new file mode 100644
index 0000000..c136d3e
--- /dev/null
+++ b/crates/kino-ui/src/resp.rs
@@ -0,0 +1,6 @@
+#[derive(Clone, Copy, Debug, Default)]
+pub struct WidgetResponse {
+    pub clicked: bool,
+    pub hovering: bool,
+    pub checked: bool,
+}
\ No newline at end of file
diff --git a/crates/kino-ui/src/shaders/ui.wgsl b/crates/kino-ui/src/shaders/ui.wgsl
new file mode 100644
index 0000000..597bb39
--- /dev/null
+++ b/crates/kino-ui/src/shaders/ui.wgsl
@@ -0,0 +1,38 @@
+@group(0) @binding(0)
+var texture_binding: texture_2d<f32>;
+
+@group(0) @binding(1)
+var texture_sampler: sampler;
+
+struct CameraUniform {
+    view_proj: mat4x4<f32>,
+};
+
+@group(1) @binding(0)
+var<uniform> camera: CameraUniform;
+
+struct VertexInput {
+    @location(0) position: vec2<f32>,
+    @location(1) colour: vec4<f32>,
+    @location(2) tex_coords: vec2<f32>,
+};
+
+struct VertexOutput {
+    @builtin(position) position: vec4<f32>,
+    @location(0) colour: vec4<f32>,
+    @location(1) tex_coords: vec2<f32>,
+};
+
+@vertex
+fn vs_main(input: VertexInput) -> VertexOutput {
+    var output: VertexOutput;
+    output.position = camera.view_proj * vec4<f32>(input.position, 0.0, 1.0);
+    output.colour = input.colour;
+    output.tex_coords = input.tex_coords;
+    return output;
+}
+
+@fragment
+fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
+    return textureSample(texture_binding, texture_sampler, input.tex_coords) * input.colour;
+}
\ No newline at end of file
diff --git a/crates/kino-ui/src/widgets/mod.rs b/crates/kino-ui/src/widgets/mod.rs
new file mode 100644
index 0000000..ec5ccce
--- /dev/null
+++ b/crates/kino-ui/src/widgets/mod.rs
@@ -0,0 +1,21 @@
+pub mod rect;
+
+use std::any::Any;
+use crate::{KinoState, UiNode, WidgetId};
+
+pub enum Anchor {
+    Center,
+    TopLeft,
+}
+
+pub trait NativeWidget: Send + Sync {
+    fn render(self: Box<Self>, state: &mut KinoState);
+    fn id(&self) -> WidgetId;
+    fn as_any(&self) -> &dyn Any;
+}
+
+pub trait ContaineredWidget: Send + Sync {
+    fn render(self: Box<Self>, children: Vec<UiNode>, state: &mut KinoState);
+    fn id(&self) -> WidgetId;
+    fn as_any(&self) -> &dyn Any;
+}
\ No newline at end of file
diff --git a/crates/kino-ui/src/widgets/rect.rs b/crates/kino-ui/src/widgets/rect.rs
new file mode 100644
index 0000000..062eebe
--- /dev/null
+++ b/crates/kino-ui/src/widgets/rect.rs
@@ -0,0 +1,128 @@
+use std::any::Any;
+use glam::{vec2, Mat2, Vec2};
+use crate::{KinoState, WidgetId};
+use crate::asset::Handle;
+use crate::math::Rect;
+use crate::rendering::texture::Texture;
+use crate::rendering::vertex::Vertex;
+use crate::widgets::{Anchor, NativeWidget};
+
+pub struct Rectangle {
+    /// The identifier of the widget.
+    ///
+    /// To make life easier, a text id works pretty well.
+    pub id: WidgetId,
+
+    pub anchor: Anchor,
+
+    /// The position of the rectangle
+    pub position: Vec2,
+
+    /// The size of the rectangle
+    pub size: Vec2,
+
+    /// The texture that this
+    pub texture: Option<Handle<Texture>>,
+
+    /// Colour described as RGBA.
+    ///
+    /// If a texture is applied to the colour, it will create a tinted texture on the quad.
+    pub colour: [f32; 4], // for now until colour is properly implemented
+
+    /// Rotation of the rectangle in radians
+    pub rotation: f32,
+
+    /// The UV of the textures.
+    pub uvs: [[f32; 2]; 4],
+}
+
+impl Rectangle {
+    pub fn new(id: WidgetId) -> Self {
+        Self {
+            id,
+            anchor: Anchor::TopLeft,
+            position: Vec2::ZERO,
+            size: vec2(64.0, 64.0),
+            rotation: 0.0,
+            colour: [255.0, 255.0, 255.0, 255.0],
+            uvs: [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
+            texture: None,
+        }
+    }
+
+    /// Sets the position & size from a [`Rect`].
+    pub fn with(mut self, rect: &Rect) -> Self {
+        self.position = rect.position;
+        self.size = rect.size;
+        self
+    }
+    /// Sets the anchor point of the rectangle
+    /// Defaults to [`Anchor::TopLeft`].
+    pub fn anchor(mut self, anchor: Anchor) -> Self {
+        self.anchor = anchor;
+        self
+    }
+    /// Sets the world-space position of the rectangle
+    pub fn at(mut self, position: impl Into<Vec2>) -> Self {
+        self.position = position.into();
+        self
+    }
+    /// Sets the size of the rectangle
+    pub fn size(mut self, size: Vec2) -> Self {
+        self.size = size;
+        self
+    }
+    /// Sets the color of the rectangle
+    pub fn color(mut self, colour: [f32; 4]) -> Self {
+        self.colour = colour;
+        self
+    }
+    /// Sets rotation (in radians) around the rectangle's center
+    /// 0 radians points up (positive Y), increasing clockwise
+    pub fn rotate(mut self, angle: f32) -> Self {
+        self.rotation = angle + std::f32::consts::FRAC_PI_2;
+        self
+    }
+    /// Sets the texture ID for the rectangle
+    pub fn texture(mut self, id: Handle<Texture>) -> Self {
+        self.texture = Some(id);
+        self
+    }
+    /// Custom UV coordinates
+    /// Defaults to covering the full texture ((0,0) - (1,1))
+    pub fn uv(mut self, coords: [[f32; 2]; 4]) -> Self {
+        self.uvs = coords;
+        self
+    }
+}
+
+impl NativeWidget for Rectangle {
+    fn render(self: Box<Self>, state: &mut KinoState) {
+        let offset = match self.anchor {
+            Anchor::TopLeft => Vec2::ZERO,
+            Anchor::Center => -self.size / 2.0,
+        };
+        let top_left = self.position + offset;
+        let rect = Rect::new(top_left, self.size);
+        let rot = Mat2::from_angle(self.rotation);
+        let verts: Vec<_> = rect
+            .corners()
+            .iter()
+            .zip(self.uvs.iter())
+            .map(|(&corner, &uv)| {
+                let world = rot * (corner - rect.center()) + rect.center();
+                Vertex::new(world.to_array(), self.colour, uv)
+            })
+            .collect();
+
+        state.batch.push(&verts, &[0, 1, 2, 2, 3, 0], self.texture);
+    }
+
+    fn id(&self) -> WidgetId {
+        self.id
+    }
+
+    fn as_any(&self) -> &dyn Any {
+        self
+    }
+}
\ No newline at end of file
diff --git a/crates/redback-runtime/Cargo.toml b/crates/redback-runtime/Cargo.toml
index 658ce9e..e2642bc 100644
--- a/crates/redback-runtime/Cargo.toml
+++ b/crates/redback-runtime/Cargo.toml
@@ -9,6 +9,7 @@ readme = "README.md"
 [dependencies]
 dropbear-engine = { path = "../dropbear-engine" }
 eucalyptus-core = { path = "../eucalyptus-core", features = ["runtime"], default-features = false }
+kino-ui = { path = "../kino-ui" }
 
 anyhow.workspace = true
 log.workspace = true
diff --git a/crates/redback-runtime/src/lib.rs b/crates/redback-runtime/src/lib.rs
index 491891b..8c57aa0 100644
--- a/crates/redback-runtime/src/lib.rs
+++ b/crates/redback-runtime/src/lib.rs
@@ -28,9 +28,12 @@ use std::path::PathBuf;
 use wgpu::SurfaceConfiguration;
 use winit::window::Fullscreen;
 use yakui_winit::YakuiWinit;
+use dropbear_engine::texture::Texture;
 use eucalyptus_core::physics::PhysicsState;
 use eucalyptus_core::rapier3d::prelude::*;
 use eucalyptus_core::register_components;
+use kino_ui::KinoState;
+use kino_ui::rendering::KinoWGPURenderer;
 
 mod scene;
 mod input;
@@ -146,6 +149,7 @@ pub struct PlayMode {
 
     // ui
     yakui_winit: Option<YakuiWinit>,
+    kino: Option<kino_ui::KinoState>,
 }
 
 impl PlayMode {
@@ -201,6 +205,7 @@ impl PlayMode {
                 last_vsync: true,
                 last_size: (0, 0),
             },
+            kino: None,
         };
 
         log::debug!("Created new play mode instance");
@@ -213,6 +218,8 @@ impl PlayMode {
         self.main_pipeline = Some(MainRenderPipeline::new(graphics.clone()));
         self.shader_globals = Some(GlobalsUniform::new(graphics.clone(), Some("runtime shader globals")));
         self.collider_wireframe_pipeline = Some(ColliderWireframePipeline::new(graphics.clone()));
+        
+        self.kino = Some(KinoState::new(KinoWGPURenderer::new(&graphics.device, &graphics.queue, Texture::TEXTURE_FORMAT, [graphics.viewport_texture.size.width as f32, graphics.viewport_texture.size.height as f32])))
     }
 
     fn reload_scripts_for_current_world(&mut self) {
diff --git a/crates/redback-runtime/src/scene.rs b/crates/redback-runtime/src/scene.rs
index 24d4e35..48e39d5 100644
--- a/crates/redback-runtime/src/scene.rs
+++ b/crates/redback-runtime/src/scene.rs
@@ -7,7 +7,7 @@ use eucalyptus_core::egui::CentralPanel;
 use eucalyptus_core::physics::collider::ColliderGroup;
 use eucalyptus_core::physics::collider::ColliderShapeKey;
 use eucalyptus_core::physics::collider::shader::ColliderInstanceRaw;
-use glam::{DMat4, DQuat, DVec3, Quat};
+use glam::{DMat4, DQuat, DVec3, Quat, Vec2};
 use hecs::Entity;
 use wgpu::{Color};
 use wgpu::util::DeviceExt;
@@ -35,6 +35,10 @@ use eucalyptus_core::scene::loading::{IsSceneLoaded, SceneLoadResult, SCENE_LOAD
 use crate::{PlayMode};
 use eucalyptus_core::physics::collider::shader::create_wireframe_geometry;
 use eucalyptus_core::ui::UI_CONTEXT;
+use kino_ui::math::Rect;
+use kino_ui::WidgetId;
+use kino_ui::widgets::{Anchor, NativeWidget};
+use kino_ui::widgets::rect::Rectangle;
 
 impl Scene for PlayMode {
     fn load(&mut self, graphics: Arc<SharedGraphicsContext>) {
@@ -523,21 +527,18 @@ impl Scene for PlayMode {
 
                         yakui.start();
 
-                        eucalyptus_core::ui::poll();
-
-                        // yakui::widgets::Layer::new().show(|| {
-                        //     yakui::column(|| {
-                        //         let button_response = yakui::widgets::Button::styled("My Button")
-                        //             .padding(yakui::widgets::Pad::all(10.0))
-                        //             .show();
-                        //         if button_response.clicked {
-                        //             println!("This is clicked!");
-                        //         }
-                        //     });
-                        // });
+                        // eucalyptus_core::ui::poll();
 
                         yakui.finish();
                     });
+
+                    if let Some(kino) = &mut self.kino {
+                        kino.add_widget(Box::new(
+                            Rectangle::new("rect".into())
+                        ));
+
+                        kino.poll();
+                    }
                 } else {
                     log::warn!("No such camera exists in the world");
                 }
@@ -913,21 +914,30 @@ impl Scene for PlayMode {
                 log_once::error_once!("{}", e);
             }
 
-            UI_CONTEXT.with(|v| {
-                let commands = graphics.yakui_renderer.lock().paint(
-                    &mut v.borrow().yakui_state.lock(),
-                    &graphics.device,
-                    &graphics.queue,
-                    SurfaceInfo {
-                        format: Texture::TEXTURE_FORMAT,
-                        sample_count: 1,
-                        color_attachment: &graphics.viewport_texture.view,
-                        resolve_target: None,
-                    }
-                );
 
-                graphics.queue.submit([commands]);
-            });
+            // UI_CONTEXT.with(|v| {
+            //     let commands = graphics.yakui_renderer.lock().paint(
+            //         &mut v.borrow().yakui_state.lock(),
+            //         &graphics.device,
+            //         &graphics.queue,
+            //         SurfaceInfo {
+            //             format: Texture::TEXTURE_FORMAT,
+            //             sample_count: 1,
+            //             color_attachment: &graphics.viewport_texture.view,
+            //             resolve_target: None,
+            //         }
+            //     );
+            //
+            //     graphics.queue.submit([commands]);
+            // });
+        }
+
+        if let Some(kino) = &mut self.kino {
+            let mut encoder = CommandEncoder::new(graphics.clone(), Some("kino encoder"));
+            kino.render(&graphics.device, &graphics.queue, &mut encoder, &graphics.viewport_texture.view);
+            if let Err(e) = encoder.submit(graphics.clone()) {
+                log_once::error_once!("Unable to submit kino: {}", e);
+            }
         }
     }