kitgit

tirbofish/dropbear · diff

2da0587 · Thribhu K

feature: billboarding (on the path to particles). can render from other texture views, just specifically got it to render from kino_ui. feature?: created a new crate called dropbear-utils that contains standard utilities todo: implement interaction with billboard (using a raycast or smth)

Unverified

diff --git a/.gitignore b/.gitignore
index 72b7d5c..376184b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,4 +55,4 @@ Cargo.lock
 .idea/
 .env
 libs/
-.env
\ No newline at end of file
+crates/dropbear_future-queue/test.log
\ No newline at end of file
diff --git a/crates/dropbear-engine/src/billboarding.rs b/crates/dropbear-engine/src/billboarding.rs
new file mode 100644
index 0000000..ec79992
--- /dev/null
+++ b/crates/dropbear-engine/src/billboarding.rs
@@ -0,0 +1,241 @@
+use std::sync::Arc;
+use glam::Mat4;
+use wgpu::MultisampleState;
+use wgpu::util::{BufferInitDescriptor, DeviceExt};
+use crate::asset::ASSET_REGISTRY;
+use crate::buffer::UniformBuffer;
+use crate::graphics::SharedGraphicsContext;
+use crate::shader::Shader;
+
+pub struct BillboardPipeline {
+    pipeline: wgpu::RenderPipeline,
+    transform_buffer: UniformBuffer<Mat4>,
+    projection_buffer: UniformBuffer<Mat4>,
+    position_buffer: wgpu::Buffer,
+    tex_coord_buffer: wgpu::Buffer,
+    uniform_bind_group_layout: wgpu::BindGroupLayout,
+    sampler: wgpu::Sampler,
+}
+
+impl BillboardPipeline {
+    pub fn new(
+        graphics: Arc<SharedGraphicsContext>,
+    ) -> Self {
+        puffin::profile_function!();
+        log::debug!("Initialising billboard pipeline");
+        let shader = Shader::new(
+            graphics.clone(),
+            include_str!("shaders/billboard.wgsl"),
+            Some("billboard shader"),
+        );
+
+        let positions: [[f32; 3]; 4] = [
+            [10.0, -10.0, 0.0],
+            [-10.0, -10.0, 0.0],
+            [10.0, 10.0, 0.0],
+            [-10.0, 10.0, 0.0],
+        ];
+
+        let position_buffer = graphics.device.create_buffer_init(&BufferInitDescriptor {
+            label: Some("billboard position buffer"),
+            contents: bytemuck::cast_slice(&positions),
+            usage: wgpu::BufferUsages::VERTEX,
+        });
+
+        let tex_coords: [[f32; 2]; 4] = [
+            [1.0, 1.0],
+            [1.0, 0.0],
+            [0.0, 1.0],
+            [0.0, 0.0],
+        ];
+
+        let tex_coord_buffer = graphics.device.create_buffer_init(&BufferInitDescriptor {
+            label: Some("billboard tex coords buffer"),
+            contents: bytemuck::cast_slice(&tex_coords),
+            usage: wgpu::BufferUsages::VERTEX,
+        });
+
+        let sampler = graphics.device.create_sampler(&wgpu::SamplerDescriptor {
+            label: Some("billboard sampler"),
+            address_mode_u: wgpu::AddressMode::Repeat,
+            address_mode_v: wgpu::AddressMode::Repeat,
+            mag_filter: wgpu::FilterMode::Linear,
+            min_filter: wgpu::FilterMode::Linear,
+            mipmap_filter: wgpu::FilterMode::Linear,
+            ..Default::default()
+        });
+
+        let uniform_bind_group_layout =
+            graphics.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+                label: Some("billboard group layout"),
+                entries: &[
+                    // transform
+                    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,
+                    },
+                    // projection
+                    wgpu::BindGroupLayoutEntry {
+                        binding: 1,
+                        visibility: wgpu::ShaderStages::VERTEX,
+                        ty: wgpu::BindingType::Buffer {
+                            ty: wgpu::BufferBindingType::Uniform,
+                            has_dynamic_offset: false,
+                            min_binding_size: None,
+                        },
+                        count: None,
+                    },
+                    // t_diffuse
+                    wgpu::BindGroupLayoutEntry {
+                        binding: 2,
+                        visibility: wgpu::ShaderStages::FRAGMENT,
+                        ty: wgpu::BindingType::Texture {
+                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
+                            view_dimension: wgpu::TextureViewDimension::D2,
+                            multisampled: false,
+                        },
+                        count: None,
+                    },
+                    // s_diffuse
+                    wgpu::BindGroupLayoutEntry {
+                        binding: 3,
+                        visibility: wgpu::ShaderStages::FRAGMENT,
+                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
+                        count: None,
+                    },
+                ],
+            });
+
+        let transform_buffer: UniformBuffer<Mat4> = UniformBuffer::new(&graphics.device, "billboard transform buffer");
+        let projection_buffer: UniformBuffer<Mat4> = UniformBuffer::new(&graphics.device, "billboard projection buffer");
+
+        {
+            let mut registry = ASSET_REGISTRY.write();
+            let handle = registry.solid_texture_rgba8(graphics.clone(), [0, 0, 0, 0]); // make transparent for now
+            let _ = registry.get_texture(handle);
+        }
+
+        let pipeline_layout =
+            graphics.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+                label: Some("billboard pipeline layout"),
+                bind_group_layouts: &[&uniform_bind_group_layout],
+                push_constant_ranges: &[],
+            });
+
+        let format = {
+            graphics.hdr.read().format().clone()
+        };
+        let pipeline = graphics.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+            label: Some("billboard render pipeline"),
+            layout: Some(&pipeline_layout),
+            vertex: wgpu::VertexState {
+                module: &shader,
+                entry_point: Some("vs_main"),
+                compilation_options: Default::default(),
+                buffers: &[
+                    // positions
+                    wgpu::VertexBufferLayout {
+                        array_stride: (std::mem::size_of::<f32>() * 3) as u64,
+                        step_mode: wgpu::VertexStepMode::Vertex,
+                        attributes: &wgpu::vertex_attr_array![0 => Float32x3],
+                    },
+                    // tex coords
+                    wgpu::VertexBufferLayout {
+                        array_stride: (std::mem::size_of::<f32>() * 2) as u64,
+                        step_mode: wgpu::VertexStepMode::Vertex,
+                        attributes: &wgpu::vertex_attr_array![1 => Float32x2],
+                    },
+                ],
+            },
+            primitive: wgpu::PrimitiveState {
+                topology: wgpu::PrimitiveTopology::TriangleStrip,
+                front_face: wgpu::FrontFace::Cw,
+                cull_mode: None,
+                ..Default::default()
+            },
+            depth_stencil: Some(wgpu::DepthStencilState {
+                format: graphics.depth_texture.texture.format(),
+                depth_write_enabled: false,
+                depth_compare: wgpu::CompareFunction::Greater,
+                stencil: Default::default(),
+                bias: Default::default(),
+            }),
+            multisample: MultisampleState {
+                count: (*graphics.antialiasing.read()).into(),
+                mask: !0,
+                alpha_to_coverage_enabled: false,
+            },
+            fragment: Some(wgpu::FragmentState {
+                module: &shader,
+                entry_point: Some("fs_main"),
+                compilation_options: Default::default(),
+                targets: &[Some(wgpu::ColorTargetState {
+                    format,
+                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
+                    write_mask: wgpu::ColorWrites::ALL,
+                })],
+            }),
+            multiview: None,
+            cache: None,
+        });
+
+        log::debug!("Created billboard pipeline");
+
+        Self {
+            pipeline,
+            transform_buffer,
+            projection_buffer,
+            position_buffer,
+            tex_coord_buffer,
+            uniform_bind_group_layout,
+            sampler,
+        }
+    }
+
+    pub fn draw(
+        &self,
+        graphics: Arc<SharedGraphicsContext>,
+        render_pass: &mut wgpu::RenderPass<'_>,
+        transform: Mat4,
+        projection: Mat4,
+        texture_view: &wgpu::TextureView,
+    ) {
+        self.transform_buffer.write(&graphics.queue, &transform);
+        self.projection_buffer.write(&graphics.queue, &projection);
+
+        let uniform_bind_group = graphics.device.create_bind_group(&wgpu::BindGroupDescriptor {
+            label: Some("billboard bind group"),
+            layout: &self.uniform_bind_group_layout,
+            entries: &[
+                wgpu::BindGroupEntry {
+                    binding: 0,
+                    resource: self.transform_buffer.buffer().as_entire_binding(),
+                },
+                wgpu::BindGroupEntry {
+                    binding: 1,
+                    resource: self.projection_buffer.buffer().as_entire_binding(),
+                },
+                wgpu::BindGroupEntry {
+                    binding: 2,
+                    resource: wgpu::BindingResource::TextureView(texture_view),
+                },
+                wgpu::BindGroupEntry {
+                    binding: 3,
+                    resource: wgpu::BindingResource::Sampler(&self.sampler),
+                },
+            ],
+        });
+
+        render_pass.set_pipeline(&self.pipeline);
+        render_pass.set_vertex_buffer(0, self.position_buffer.slice(..));
+        render_pass.set_vertex_buffer(1, self.tex_coord_buffer.slice(..));
+        render_pass.set_bind_group(0, &uniform_bind_group, &[]);
+        render_pass.draw(0..4, 0..1);
+    }
+}
\ No newline at end of file
diff --git a/crates/dropbear-engine/src/lib.rs b/crates/dropbear-engine/src/lib.rs
index 415f737..34c1483 100644
--- a/crates/dropbear-engine/src/lib.rs
+++ b/crates/dropbear-engine/src/lib.rs
@@ -23,6 +23,7 @@ pub mod sky;
 pub mod texture;
 pub mod utils;
 pub mod multisampling;
+pub mod billboarding;
 
 features! {
     pub mod feature_list {
diff --git a/crates/dropbear-engine/src/shaders/billboard.wgsl b/crates/dropbear-engine/src/shaders/billboard.wgsl
new file mode 100644
index 0000000..0fcabef
--- /dev/null
+++ b/crates/dropbear-engine/src/shaders/billboard.wgsl
@@ -0,0 +1,31 @@
+@group(0) @binding(0)
+var<uniform> transform: mat4x4<f32>;
+
+@group(0) @binding(1)
+var<uniform> projection: mat4x4<f32>;
+
+@group(0) @binding(2)
+var t_diffuse: texture_2d<f32>;
+@group(0) @binding(3)
+var s_diffuse: sampler;
+
+struct VertexOutput {
+    @builtin(position) clip_position: vec4<f32>,
+    @location(0) tex_coords: vec2<f32>,
+};
+
+@vertex
+fn vs_main(
+    @location(0) inPos: vec3<f32>,
+    @location(1) inTexCoords: vec2<f32>
+) -> VertexOutput {
+    var out: VertexOutput;
+    out.clip_position = projection * transform * vec4<f32>(inPos, 1.0);
+    out.tex_coords = inTexCoords;
+    return out;
+}
+
+@fragment
+fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
+    return textureSampleLevel(t_diffuse, s_diffuse, in.tex_coords, 0);
+}
\ No newline at end of file
diff --git a/crates/dropbear-utils/Cargo.toml b/crates/dropbear-utils/Cargo.toml
new file mode 100644
index 0000000..dac27af
--- /dev/null
+++ b/crates/dropbear-utils/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "dropbear-utils"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+readme = "README.md"
+authors.workspace = true
+
+[dependencies]
diff --git a/crates/dropbear-utils/README.md b/crates/dropbear-utils/README.md
new file mode 100644
index 0000000..90b1dd9
--- /dev/null
+++ b/crates/dropbear-utils/README.md
@@ -0,0 +1,3 @@
+# dropbear-utils
+
+Utility structs, enums and functions for the dropbear project (could be useful for other external projects). 
\ No newline at end of file
diff --git a/crates/dropbear-utils/src/hashmap.rs b/crates/dropbear-utils/src/hashmap.rs
new file mode 100644
index 0000000..4f052ed
--- /dev/null
+++ b/crates/dropbear-utils/src/hashmap.rs
@@ -0,0 +1,271 @@
+use std::collections::HashMap;
+
+/// A wrapper for a [HashMap] that iterates its generation for each access. It is able to check
+/// for any stale values, and remove them in [StaleTracker::remove_stale].
+///
+/// # Types
+/// * `K` - The key type, must implement `Eq` and `Hash`
+/// * `V` - The value type
+///
+/// # Examples
+///
+/// ```
+/// let mut tracker = dropbear_utils::StaleTracker::new();
+///
+/// // insert some values
+/// tracker.insert("session_1", "user_data");
+/// tracker.insert("session_2", "other_data");
+///
+/// // tick/update
+/// // current generation is 0
+/// tracker.tick();
+///
+/// // access session_1 to keep it fresh
+/// // session_1 is now at 0
+/// tracker.get(&"session_1");
+///
+/// // tick once more
+/// // current generation is now 1
+/// tracker.tick();
+///
+/// // remove entries older than 0 generations
+/// // session_2 hasn't been accessed, therefore it will be removed
+/// let removed = tracker.remove_stale(1);
+/// assert_eq!(removed, vec!["session_2"]);
+/// assert_eq!(tracker.len(), 1);
+/// ```
+pub struct StaleTracker<K, V> {
+    map: HashMap<K, (V, usize)>, // (value, last_access_generation)
+    current_generation: usize,
+}
+
+impl<K: Eq + std::hash::Hash, V> StaleTracker<K, V> {
+    /// Creates a new empty `StaleTracker`.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use dropbear_utils::StaleTracker;
+    /// let tracker: StaleTracker<String, i32> = StaleTracker::new();
+    /// ```
+    pub fn new() -> Self {
+        Self {
+            map: HashMap::new(),
+            current_generation: 0,
+        }
+    }
+
+    /// Inserts a key-value pair into the tracker.
+    ///
+    /// The entry is marked as accessed at the current generation. If the key already
+    /// exists, the old value is replaced and its access time is reset to the current generation.
+    ///
+    /// # Arguments
+    ///
+    /// * `key` - The key to insert
+    /// * `value` - The value to associate with the key
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use dropbear_utils::StaleTracker;
+    /// let mut tracker = StaleTracker::new();
+    /// tracker.insert("key", 42);
+    /// ```
+    pub fn insert(&mut self, key: K, value: V) {
+        self.map.insert(key, (value, self.current_generation));
+    }
+
+    /// Gets a reference to the value associated with the key.
+    ///
+    /// This method marks the entry as accessed at the current generation, preventing it
+    /// from being considered stale. Returns `None` if the key doesn't exist.
+    ///
+    /// # Arguments
+    ///
+    /// * `key` - The key to look up
+    ///
+    /// # Returns
+    ///
+    /// * `Some(&V)` if the key exists
+    /// * `None` if the key doesn't exist
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use dropbear_utils::StaleTracker;
+    /// let mut tracker = StaleTracker::new();
+    /// tracker.insert("key", 42);
+    ///
+    /// assert_eq!(tracker.get(&"key"), Some(&42));
+    /// assert_eq!(tracker.get(&"missing"), None);
+    /// ```
+    pub fn get(&mut self, key: &K) -> Option<&V> {
+        self.map.get_mut(key).map(|(value, generation)| {
+            *generation = self.current_generation;
+            &*value
+        })
+    }
+
+    /// Gets a mutable reference to the value associated with the key.
+    ///
+    /// This method marks the entry as accessed at the current generation. Returns `None`
+    /// if the key doesn't exist.
+    ///
+    /// # Arguments
+    ///
+    /// * `key` - The key to look up
+    ///
+    /// # Returns
+    ///
+    /// * `Some(&mut V)` if the key exists
+    /// * `None` if the key doesn't exist
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use dropbear_utils::StaleTracker;
+    /// let mut tracker = StaleTracker::new();
+    /// tracker.insert("counter", 0);
+    ///
+    /// if let Some(value) = tracker.get_mut(&"counter") {
+    ///     *value += 1;
+    /// }
+    /// ```
+    pub fn get_mut(&mut self, key: &K) -> Option<&mut V> {
+        self.map.get_mut(key).map(|(value, generation)| {
+            *generation = self.current_generation;
+            value
+        })
+    }
+
+    /// Advances the generation counter by one.
+    ///
+    /// Call this method periodically (e.g., once per frame, once per request, etc.) to
+    /// mark a new time period. Entries that aren't accessed after calling `tick()` will
+    /// age and eventually become stale.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use dropbear_utils::StaleTracker;
+    /// let mut tracker = StaleTracker::new();
+    /// tracker.insert("key", 42);
+    ///
+    /// // iterate
+    /// tracker.tick();
+    /// tracker.tick();
+    ///
+    /// // "key" is now 2 generations old (hasn't been accessed since insertion)
+    /// ```
+    pub fn tick(&mut self) {
+        self.current_generation += 1;
+    }
+
+    /// Removes and returns all entries that haven't been accessed within `max_age` generations.
+    ///
+    /// An entry is considered stale if `(current_generation - last_access_generation) > max_age`.
+    ///
+    /// # Arguments
+    ///
+    /// * `max_age` - Maximum number of generations an entry can go without access before removal
+    ///
+    /// # Returns
+    ///
+    /// A vector of keys that were removed
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use dropbear_utils::StaleTracker;
+    /// let mut tracker = StaleTracker::new();
+    ///
+    /// tracker.insert("fresh", 1);
+    /// tracker.insert("stale", 2);
+    ///
+    /// tracker.get(&"fresh"); // access this one
+    /// tracker.tick();        // advance generation
+    /// tracker.tick();        // advance again
+    ///
+    /// // "stale" hasn't been accessed in 2 generations
+    /// let removed = tracker.remove_stale(1);
+    /// assert!(removed.contains(&"stale"));
+    /// assert!(!removed.contains(&"fresh"));
+    /// ```
+    pub fn remove_stale(&mut self, max_age: usize) -> Vec<K>
+    where
+        K: Clone,
+    {
+        let current = self.current_generation;
+        let stale_keys: Vec<K> = self
+            .map
+            .iter()
+            .filter(|(_, (_, generation))| current - generation > max_age)
+            .map(|(k, _)| k.clone())
+            .collect();
+
+        for key in &stale_keys {
+            self.map.remove(key);
+        }
+
+        stale_keys
+    }
+
+    /// Returns the number of entries currently in the tracker.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use dropbear_utils::StaleTracker;
+    /// let mut tracker = StaleTracker::new();
+    /// assert_eq!(tracker.len(), 0);
+    ///
+    /// tracker.insert("key", 42);
+    /// assert_eq!(tracker.len(), 1);
+    /// ```
+    pub fn len(&self) -> usize {
+        self.map.len()
+    }
+
+    /// Returns `true` if the tracker contains no entries.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use dropbear_utils::StaleTracker;
+    /// let tracker: StaleTracker<String, i32> = StaleTracker::new();
+    /// assert!(tracker.is_empty());
+    /// ```
+    pub fn is_empty(&self) -> bool {
+        self.map.is_empty()
+    }
+
+    /// Returns the current generation number.
+    ///
+    /// This can be useful for debugging or understanding how much time has passed.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use dropbear_utils::StaleTracker;
+    /// let mut tracker = StaleTracker::new();
+    /// assert_eq!(tracker.current_generation(), 0);
+    ///
+    /// tracker.tick();
+    /// assert_eq!(tracker.current_generation(), 1);
+    /// ```
+    pub fn current_generation(&self) -> usize {
+        self.current_generation
+    }
+
+    /// Iterates over key/value pairs without updating access generation.
+    pub fn iter(&self) -> impl Iterator<Item = (&K, &V)> {
+        self.map.iter().map(|(key, (value, _generation))| (key, value))
+    }
+}
+
+impl<K: Eq + std::hash::Hash, V> Default for StaleTracker<K, V> {
+    fn default() -> Self {
+        Self::new()
+    }
+}
diff --git a/crates/dropbear-utils/src/lib.rs b/crates/dropbear-utils/src/lib.rs
new file mode 100644
index 0000000..16eeb44
--- /dev/null
+++ b/crates/dropbear-utils/src/lib.rs
@@ -0,0 +1,3 @@
+mod hashmap;
+
+pub use hashmap::*;
\ No newline at end of file
diff --git a/crates/eucalyptus-core/src/billboard.rs b/crates/eucalyptus-core/src/billboard.rs
new file mode 100644
index 0000000..60d5405
--- /dev/null
+++ b/crates/eucalyptus-core/src/billboard.rs
@@ -0,0 +1,119 @@
+use std::sync::Arc;
+use hecs::{Entity, World};
+use dropbear_engine::graphics::SharedGraphicsContext;
+use crate::component::{Component, ComponentDescriptor, ComponentInitFuture, InspectableComponent, SerializedComponent};
+use crate::physics::PhysicsState;
+use egui::{CollapsingHeader, Ui};
+
+fn default_world_size() -> glam::Vec2 {
+    glam::Vec2::ONE
+}
+
+fn default_enabled() -> bool {
+    true
+}
+
+#[derive(Clone, serde::Serialize, serde::Deserialize)]
+pub struct BillboardComponent {
+    #[serde(default)]
+    pub offset: glam::Vec3,
+    #[serde(default)]
+    pub rotation: Option<glam::Quat>, // if None, billboard is facing the camera
+    #[serde(default = "default_world_size")]
+    pub world_size: glam::Vec2,
+    #[serde(default = "default_enabled")]
+    pub enabled: bool,
+}
+
+impl Default for BillboardComponent {
+    fn default() -> Self {
+        Self {
+            offset: glam::Vec3::ZERO,
+            rotation: None,
+            world_size: glam::Vec2::ONE,
+            enabled: true,
+        }
+    }
+}
+
+#[typetag::serde]
+impl SerializedComponent for BillboardComponent {}
+
+impl Component for BillboardComponent {
+    type SerializedForm = Self;
+    type RequiredComponentTypes = (Self, );
+
+    fn descriptor() -> ComponentDescriptor {
+        ComponentDescriptor {
+            fqtn: "eucalyptus_core::billboard::BillboardComponent".to_string(),
+            type_name: "Billboard".to_string(),
+            category: Some("Rendering".to_string()),
+            description: Some("Renders a camera-facing textured quad".to_string()),
+        }
+    } 
+
+    fn init(ser: &'_ Self::SerializedForm, _graphics: Arc<SharedGraphicsContext>) -> ComponentInitFuture<'_, Self> {
+        Box::pin(async move {
+            Ok((ser.clone(),))
+        })
+    }
+
+    fn update_component(&mut self, _world: &World, _physics: &mut PhysicsState, _entity: Entity, _dt: f32, _graphics: Arc<SharedGraphicsContext>) {
+    }
+
+    fn save(&self, _world: &World, _entity: Entity) -> Box<dyn SerializedComponent> {
+        Box::new(self.clone())
+    }
+}
+
+impl InspectableComponent for BillboardComponent {
+    fn inspect(
+        &mut self,
+        _world: &World,
+        _entity: Entity,
+        ui: &mut Ui,
+        _graphics: Arc<SharedGraphicsContext>,
+    ) {
+        CollapsingHeader::new("Billboard")
+            .default_open(true)
+            .show(ui, |ui| {
+                ui.checkbox(&mut self.enabled, "Enabled");
+
+                ui.horizontal(|ui| {
+                    ui.label("Size");
+                    ui.add(egui::DragValue::new(&mut self.world_size.x).speed(0.01));
+                    ui.add(egui::DragValue::new(&mut self.world_size.y).speed(0.01));
+                });
+                self.world_size.x = self.world_size.x.max(0.001);
+                self.world_size.y = self.world_size.y.max(0.001);
+
+                ui.horizontal(|ui| {
+                    ui.label("Offset");
+                    ui.add(egui::DragValue::new(&mut self.offset.x).speed(0.01));
+                    ui.add(egui::DragValue::new(&mut self.offset.y).speed(0.01));
+                    ui.add(egui::DragValue::new(&mut self.offset.z).speed(0.01));
+                });
+
+                let mut face_camera = self.rotation.is_none();
+                if ui.checkbox(&mut face_camera, "Face Camera").changed() {
+                    if face_camera {
+                        self.rotation = None;
+                    } else {
+                        self.rotation = Some(glam::Quat::IDENTITY);
+                    }
+                }
+
+                if let Some(rotation) = &mut self.rotation {
+                    ui.horizontal(|ui| {
+                        ui.label("Rotation");
+                        ui.add(egui::DragValue::new(&mut rotation.x).speed(0.01));
+                        ui.add(egui::DragValue::new(&mut rotation.y).speed(0.01));
+                        ui.add(egui::DragValue::new(&mut rotation.z).speed(0.01));
+                        ui.add(egui::DragValue::new(&mut rotation.w).speed(0.01));
+                    });
+                    *rotation = rotation.normalize();
+                }
+
+            });
+    }
+}
\ No newline at end of file
diff --git a/crates/eucalyptus-core/src/component.rs b/crates/eucalyptus-core/src/component.rs
index 1863ad1..f548639 100644
--- a/crates/eucalyptus-core/src/component.rs
+++ b/crates/eucalyptus-core/src/component.rs
@@ -10,7 +10,6 @@ use dropbear_engine::model::Model;
 use dropbear_engine::procedural::{ProcObjType, ProcedurallyGeneratedObject};
 use dropbear_engine::texture::Texture;
 use dropbear_engine::utils::{ResourceReference, ResourceReferenceType};
-use dropbear_engine::wgpu;
 use egui::{CollapsingHeader, ComboBox, DragValue, Grid, RichText, UiBuilder};
 use hecs::{Entity, World};
 pub use serde::{Deserialize, Serialize};
diff --git a/crates/eucalyptus-core/src/lib.rs b/crates/eucalyptus-core/src/lib.rs
index d22ab22..2f00943 100644
--- a/crates/eucalyptus-core/src/lib.rs
+++ b/crates/eucalyptus-core/src/lib.rs
@@ -21,6 +21,7 @@ pub mod states;
 pub mod transform;
 pub mod types;
 pub mod utils;
+pub mod billboard;
 
 pub use dropbear_macro as macros;
 
@@ -28,6 +29,7 @@ use crate::component::ComponentRegistry;
 use crate::physics::collider::ColliderGroup;
 use crate::physics::kcc::KCC;
 use crate::physics::rigidbody::RigidBody;
+use crate::billboard::BillboardComponent;
 use crate::states::Script;
 use dropbear_engine::animation::AnimationComponent;
 use dropbear_engine::camera::Camera;
@@ -57,4 +59,5 @@ pub fn register_components(component_registry: &mut ComponentRegistry) {
     component_registry.register::<ColliderGroup>();
     component_registry.register::<KCC>();
     component_registry.register::<AnimationComponent>();
+    component_registry.register::<BillboardComponent>();
 }
diff --git a/crates/eucalyptus-core/src/utils.rs b/crates/eucalyptus-core/src/utils.rs
index 477ae1f..70db39e 100644
--- a/crates/eucalyptus-core/src/utils.rs
+++ b/crates/eucalyptus-core/src/utils.rs
@@ -1,6 +1,5 @@
 //! Utility functions and helpers
 
-pub mod hashmap;
 pub mod option;
 
 use crate::scripting::result::DropbearNativeResult;
diff --git a/crates/eucalyptus-core/src/utils/hashmap.rs b/crates/eucalyptus-core/src/utils/hashmap.rs
deleted file mode 100644
index 34e677f..0000000
--- a/crates/eucalyptus-core/src/utils/hashmap.rs
+++ /dev/null
@@ -1,249 +0,0 @@
-use std::collections::HashMap;
-
-/// A wrapper for a [HashMap] that iterates its generation for each access. It is able to check
-/// for any stale values, and remove them in [StaleTracker::remove_stale].
-///
-/// # Types
-/// * `K` - The key type, must implement `Eq` and `Hash`
-/// * `V` - The value type
-///
-/// # Examples
-///
-/// ```
-/// let mut tracker = eucalyptus_core::utils::hashmap::StaleTracker::new();
-///
-/// // Insert some values
-/// tracker.insert("session_1", "user_data");
-/// tracker.insert("session_2", "other_data");
-///
-/// // Access session_1 to keep it fresh
-/// tracker.get(&"session_1");
-///
-/// // Advance time
-/// tracker.tick();
-///
-/// // session_2 hasn't been accessed, remove entries older than 0 generations
-/// let removed = tracker.remove_stale(0);
-/// assert_eq!(removed, vec!["session_2"]);
-/// ```
-pub struct StaleTracker<K, V> {
-    map: HashMap<K, (V, usize)>, // (value, last_access_generation)
-    current_generation: usize,
-}
-
-impl<K: Eq + std::hash::Hash, V> StaleTracker<K, V> {
-    /// Creates a new empty `StaleTracker`.
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// let tracker: StaleTracker<String, i32> = StaleTracker::new();
-    /// ```
-    pub fn new() -> Self {
-        Self {
-            map: HashMap::new(),
-            current_generation: 0,
-        }
-    }
-
-    /// Inserts a key-value pair into the tracker.
-    ///
-    /// The entry is marked as accessed at the current generation. If the key already
-    /// exists, the old value is replaced and its access time is reset to the current generation.
-    ///
-    /// # Arguments
-    ///
-    /// * `key` - The key to insert
-    /// * `value` - The value to associate with the key
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// let mut tracker = StaleTracker::new();
-    /// tracker.insert("key", 42);
-    /// ```
-    pub fn insert(&mut self, key: K, value: V) {
-        self.map.insert(key, (value, self.current_generation));
-    }
-
-    /// Gets a reference to the value associated with the key.
-    ///
-    /// This method marks the entry as accessed at the current generation, preventing it
-    /// from being considered stale. Returns `None` if the key doesn't exist.
-    ///
-    /// # Arguments
-    ///
-    /// * `key` - The key to look up
-    ///
-    /// # Returns
-    ///
-    /// * `Some(&V)` if the key exists
-    /// * `None` if the key doesn't exist
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// let mut tracker = StaleTracker::new();
-    /// tracker.insert("key", 42);
-    ///
-    /// assert_eq!(tracker.get(&"key"), Some(&42));
-    /// assert_eq!(tracker.get(&"missing"), None);
-    /// ```
-    pub fn get(&mut self, key: &K) -> Option<&V> {
-        self.map.get_mut(key).map(|(value, generation)| {
-            *generation = self.current_generation;
-            &*value
-        })
-    }
-
-    /// Gets a mutable reference to the value associated with the key.
-    ///
-    /// This method marks the entry as accessed at the current generation. Returns `None`
-    /// if the key doesn't exist.
-    ///
-    /// # Arguments
-    ///
-    /// * `key` - The key to look up
-    ///
-    /// # Returns
-    ///
-    /// * `Some(&mut V)` if the key exists
-    /// * `None` if the key doesn't exist
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// let mut tracker = StaleTracker::new();
-    /// tracker.insert("counter", 0);
-    ///
-    /// if let Some(value) = tracker.get_mut(&"counter") {
-    ///     *value += 1;
-    /// }
-    /// ```
-    pub fn get_mut(&mut self, key: &K) -> Option<&mut V> {
-        self.map.get_mut(key).map(|(value, generation)| {
-            *generation = self.current_generation;
-            value
-        })
-    }
-
-    /// Advances the generation counter by one.
-    ///
-    /// Call this method periodically (e.g., once per frame, once per request, etc.) to
-    /// mark a new time period. Entries that aren't accessed after calling `tick()` will
-    /// age and eventually become stale.
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// let mut tracker = StaleTracker::new();
-    /// tracker.insert("key", 42);
-    ///
-    /// // Simulate passage of time
-    /// tracker.tick();
-    /// tracker.tick();
-    ///
-    /// // "key" is now 2 generations old (hasn't been accessed since insertion)
-    /// ```
-    pub fn tick(&mut self) {
-        self.current_generation += 1;
-    }
-
-    /// Removes and returns all entries that haven't been accessed within `max_age` generations.
-    ///
-    /// An entry is considered stale if `(current_generation - last_access_generation) > max_age`.
-    ///
-    /// # Arguments
-    ///
-    /// * `max_age` - Maximum number of generations an entry can go without access before removal
-    ///
-    /// # Returns
-    ///
-    /// A vector of keys that were removed
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// let mut tracker = StaleTracker::new();
-    ///
-    /// tracker.insert("fresh", 1);
-    /// tracker.insert("stale", 2);
-    ///
-    /// tracker.get(&"fresh"); // Access this one
-    /// tracker.tick();        // Advance generation
-    /// tracker.tick();        // Advance again
-    ///
-    /// // "stale" hasn't been accessed in 2 generations
-    /// let removed = tracker.remove_stale(1);
-    /// assert!(removed.contains(&"stale"));
-    /// assert!(!removed.contains(&"fresh"));
-    /// ```
-    pub fn remove_stale(&mut self, max_age: usize) -> Vec<K>
-    where
-        K: Clone,
-    {
-        let current = self.current_generation;
-        let stale_keys: Vec<K> = self
-            .map
-            .iter()
-            .filter(|(_, (_, generation))| current - generation > max_age)
-            .map(|(k, _)| k.clone())
-            .collect();
-
-        for key in &stale_keys {
-            self.map.remove(key);
-        }
-
-        stale_keys
-    }
-
-    /// Returns the number of entries currently in the tracker.
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// let mut tracker = StaleTracker::new();
-    /// assert_eq!(tracker.len(), 0);
-    ///
-    /// tracker.insert("key", 42);
-    /// assert_eq!(tracker.len(), 1);
-    /// ```
-    pub fn len(&self) -> usize {
-        self.map.len()
-    }
-
-    /// Returns `true` if the tracker contains no entries.
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// let tracker: StaleTracker<String, i32> = StaleTracker::new();
-    /// assert!(tracker.is_empty());
-    /// ```
-    pub fn is_empty(&self) -> bool {
-        self.map.is_empty()
-    }
-
-    /// Returns the current generation number.
-    ///
-    /// This can be useful for debugging or understanding how much time has passed.
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// let mut tracker = StaleTracker::new();
-    /// assert_eq!(tracker.current_generation(), 0);
-    ///
-    /// tracker.tick();
-    /// assert_eq!(tracker.current_generation(), 1);
-    /// ```
-    pub fn current_generation(&self) -> usize {
-        self.current_generation
-    }
-}
-
-impl<K: Eq + std::hash::Hash, V> Default for StaleTracker<K, V> {
-    fn default() -> Self {
-        Self::new()
-    }
-}
diff --git a/crates/eucalyptus-editor/src/editor/mod.rs b/crates/eucalyptus-editor/src/editor/mod.rs
index 5771f1c..ba8942b 100644
--- a/crates/eucalyptus-editor/src/editor/mod.rs
+++ b/crates/eucalyptus-editor/src/editor/mod.rs
@@ -24,6 +24,7 @@ use crate::editor::settings::project::ProjectSettingsWindow;
 use crate::plugin::PluginRegistry;
 use crate::stats::NerdStats;
 use crossbeam_channel::{Receiver, Sender, unbounded};
+use dropbear_engine::billboarding::BillboardPipeline;
 use dropbear_engine::buffer::ResizableBuffer;
 use dropbear_engine::entity::EntityTransform;
 use dropbear_engine::graphics::InstanceRaw;
@@ -61,6 +62,9 @@ use eucalyptus_core::{
 };
 use glam::Mat4;
 use hecs::{Entity, World};
+use kino_ui::KinoState;
+use kino_ui::rendering::KinoWGPURenderer;
+use kino_ui::windowing::KinoWinitWindowing;
 use log::{debug, error};
 use parking_lot::{Mutex, RwLock};
 use rfd::FileDialog;
@@ -97,6 +101,8 @@ pub struct Editor {
     pub collider_wireframe_pipeline: Option<ColliderWireframePipeline>,
     pub mipmapper: Option<MipMapper>,
     pub sky_pipeline: Option<SkyPipeline>,
+    pub billboard_pipeline: Option<BillboardPipeline>,
+    pub kino: Option<KinoState>,
     pub(crate) camera_bind_group: Option<wgpu::BindGroup>,
     pub(crate) default_skinning_buffer: Option<wgpu::Buffer>,
     pub(crate) default_morph_deltas_buffer: Option<wgpu::Buffer>,
@@ -309,6 +315,8 @@ impl Editor {
             collider_instance_buffer: None,
             mipmapper: None,
             sky_pipeline: None,
+            billboard_pipeline: None,
+            kino: None,
             camera_bind_group: None,
             default_skinning_buffer: None,
             default_morph_deltas_buffer: None,
@@ -1411,6 +1419,19 @@ impl Editor {
         ));
         self.collider_wireframe_pipeline = Some(ColliderWireframePipeline::new(graphics.clone()));
         self.mipmapper = None;
+        self.billboard_pipeline = Some(BillboardPipeline::new(graphics.clone()));
+        self.kino = Some(KinoState::new(
+            KinoWGPURenderer::new(
+                &graphics.device,
+                &graphics.queue,
+                graphics.hdr.read().format(),
+                [
+                    graphics.viewport_texture.size.width as f32,
+                    graphics.viewport_texture.size.height as f32,
+                ],
+            ),
+            KinoWinitWindowing::new(graphics.window.clone(), None),
+        ));
 
         self.camera_bind_group = None;
         self.default_skinning_buffer = None;
diff --git a/crates/eucalyptus-editor/src/editor/scene.rs b/crates/eucalyptus-editor/src/editor/scene.rs
index 2ae6d6d..6be5423 100644
--- a/crates/eucalyptus-editor/src/editor/scene.rs
+++ b/crates/eucalyptus-editor/src/editor/scene.rs
@@ -12,11 +12,13 @@ use dropbear_engine::{
     model::{DrawLight, DrawModel},
     scene::{Scene, SceneCommand},
 };
+use eucalyptus_core::billboard::BillboardComponent;
 use eucalyptus_core::physics::collider::ColliderGroup;
 use eucalyptus_core::physics::collider::ColliderShapeKey;
 use eucalyptus_core::physics::collider::shader::{ColliderInstanceRaw, create_wireframe_geometry};
 use eucalyptus_core::properties::CustomProperties;
 use eucalyptus_core::states::{Label, WorldLoadingStatus};
+use glam::vec2;
 use hecs::Entity;
 use log;
 use parking_lot::Mutex;
@@ -28,6 +30,8 @@ use std::{
 };
 use winit::{event::WindowEvent, event_loop::ActiveEventLoop, keyboard::KeyCode};
 use winit::event::{MouseScrollDelta, TouchPhase};
+use kino_ui::rendering::KinoRenderTargetId;
+use kino_ui::widgets::Fill;
 
 impl Scene for Editor {
     fn load(&mut self, graphics: std::sync::Arc<dropbear_engine::graphics::SharedGraphicsContext>) {
@@ -318,6 +322,21 @@ impl Scene for Editor {
                 .record_stats(dt, self.world.len() as u32);
         }
 
+        if let Some(kino) = &mut self.kino {
+            for (e, _) in self.world.query::<(Entity, &BillboardComponent)>().iter() {
+                kino.begin(KinoRenderTargetId::Billboard(e.to_bits().get()));
+
+                // todo: remove after testing
+                kino_ui::rect(kino, "rect", |rect| {
+                    rect.fill = Fill::new([1.0, 0.0, 0.0, 1.0]);
+                    rect.anchor = kino_ui::widgets::Anchor::Center;
+                    rect.size = vec2(500.0, 300.0);
+                });
+
+                kino.flush();
+            }
+        }
+
         self.input_state.window = self.window.clone();
         self.previously_selected_entity = self.selected_entity;
         self.input_state.mouse_delta = None;
@@ -788,6 +807,7 @@ impl Scene for Editor {
             }
         }
 
+        // skybox rendering
         if let Some(sky) = &self.sky_pipeline {
             puffin::profile_scope!("sky render pass");
             let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
@@ -949,11 +969,137 @@ impl Scene for Editor {
             }
         }
 
+        // kino billboard renderer (late stage, runtime parity)
+        {
+            if let Some(kino) = &mut self.kino {
+                let mut kino_encoder = CommandEncoder::new(graphics.clone(), Some("kino billboard encoder"));
+                kino.render_billboard_targets(
+                    &graphics.device,
+                    &graphics.queue,
+                    &mut kino_encoder,
+                );
+
+                if let Err(e) = kino_encoder.submit() {
+                    log_once::error_once!("Unable to submit billboard kino pass: {}", e);
+                }
+            }
+
+            if let Some(billboard_pipeline) = &self.billboard_pipeline {
+                let camera_position = camera.position().as_vec3();
+                let camera_projection = Mat4::from_cols_array_2d(&camera.uniform.view_proj);
+
+                let mut kino_views = HashMap::<u64, wgpu::TextureView>::new();
+                if let Some(kino) = &mut self.kino {
+                    kino_views.extend(kino.billboard_render_target_views());
+                }
+
+                let single_fallback_view = if kino_views.len() == 1 {
+                    kino_views.values().next().cloned()
+                } else {
+                    None
+                };
+
+                let mut billboards: Vec<(Mat4, wgpu::TextureView)> = Vec::new();
+                let mut query = self
+                    .world
+                    .query::<(Entity, &BillboardComponent, Option<&EntityTransform>)>();
+
+                for (entity, billboard, entity_transform) in query.iter() {
+                    if !billboard.enabled {
+                        continue;
+                    }
+
+                    let entity_id = entity.to_bits().get();
+                    let texture_view = kino_views
+                        .get(&entity_id)
+                        .cloned()
+                        .or_else(|| single_fallback_view.clone());
+
+                    let Some(texture_view) = texture_view else {
+                        continue;
+                    };
+
+                    let position = entity_transform
+                        .map(|transform| transform.sync().position.as_vec3())
+                        .unwrap_or(glam::Vec3::ZERO)
+                        + billboard.offset;
+                    let world_size = billboard.world_size;
+                    let scale = glam::Vec3::new(world_size.x, world_size.y, 1.0);
+
+                    let rotation = if let Some(rotation) = billboard.rotation {
+                        rotation
+                    } else {
+                        let to_camera = (camera_position - position).normalize_or_zero();
+                        if to_camera.length_squared() > 0.0 {
+                            let mut world_up = glam::Vec3::Y;
+                            if to_camera.dot(world_up).abs() > 0.999 {
+                                world_up = glam::Vec3::X;
+                            }
+
+                            let right = world_up.cross(to_camera).normalize_or_zero();
+                            let up = to_camera.cross(right).normalize_or_zero();
+                            let basis = glam::Mat3::from_cols(right, up, to_camera);
+                            glam::Quat::from_mat3(&basis)
+                        } else {
+                            glam::Quat::IDENTITY
+                        }
+                    };
+
+                    let transform = Mat4::from_scale_rotation_translation(scale, rotation, position);
+                    billboards.push((transform, texture_view));
+                }
+
+                if !billboards.is_empty() {
+                    let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+                        label: Some("editor billboard render pass"),
+                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+                            view: hdr.render_view(),
+                            depth_slice: None,
+                            resolve_target: hdr.resolve_target(),
+                            ops: wgpu::Operations {
+                                load: wgpu::LoadOp::Load,
+                                store: wgpu::StoreOp::Store,
+                            },
+                        })],
+                        depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
+                            view: &graphics.depth_texture.view,
+                            depth_ops: Some(wgpu::Operations {
+                                load: wgpu::LoadOp::Load,
+                                store: wgpu::StoreOp::Store,
+                            }),
+                            stencil_ops: None,
+                        }),
+                        timestamp_writes: None,
+                        occlusion_query_set: None,
+                    });
+
+                    for (transform, texture_view) in billboards {
+                        billboard_pipeline.draw(
+                            graphics.clone(),
+                            &mut render_pass,
+                            transform,
+                            camera_projection,
+                            &texture_view,
+                        );
+                    }
+                }
+            }
+        }
+
         hdr.process(&mut encoder, &graphics.viewport_texture.view);
 
         if let Err(e) = encoder.submit() {
             log_once::error_once!("{}", e);
         }
+
+        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, hdr.view());
+
+            if let Err(e) = encoder.submit() {
+                log_once::error_once!("Unable to submit kino: {}", e);
+            }
+        }
     }
 
     fn exit(&mut self, _event_loop: &ActiveEventLoop) {}
diff --git a/crates/eucalyptus-editor/src/main.rs b/crates/eucalyptus-editor/src/main.rs
index 75540f9..df515a2 100644
--- a/crates/eucalyptus-editor/src/main.rs
+++ b/crates/eucalyptus-editor/src/main.rs
@@ -103,6 +103,7 @@ async fn main() -> anyhow::Result<()> {
             .filter(Some("eucalyptus_editor"), LevelFilter::Debug)
             .filter(Some("eucalyptus_core"), LevelFilter::Debug)
             .filter(Some("dropbear_traits"), LevelFilter::Debug)
+            .filter(Some("redback_runtime"), LevelFilter::Debug)
             .filter(Some("kino_ui"), LevelFilter::Debug)
             .init();
         log::info!("Initialised logger");
diff --git a/crates/kino-ui/Cargo.toml b/crates/kino-ui/Cargo.toml
index b59a6ce..550ecc7 100644
--- a/crates/kino-ui/Cargo.toml
+++ b/crates/kino-ui/Cargo.toml
@@ -8,6 +8,8 @@ readme.workspace = true
 authors.workspace = true
 
 [dependencies]
+dropbear-utils = { path = "../dropbear-utils" }
+
 wgpu.workspace = true
 parking_lot.workspace = true
 glam.workspace = true
diff --git a/crates/kino-ui/src/lib.rs b/crates/kino-ui/src/lib.rs
index 3f48720..7c0dc2a 100644
--- a/crates/kino-ui/src/lib.rs
+++ b/crates/kino-ui/src/lib.rs
@@ -19,8 +19,8 @@ pub use widgets::shorthand::*;
 use crate::asset::{AssetServer, Handle};
 use crate::camera::Camera2D;
 use crate::math::Rect;
-use crate::rendering::KinoWGPURenderer;
-use crate::rendering::text::KinoTextRenderer;
+use crate::rendering::{KinoRenderContext, KinoRenderTargetId, KinoWGPURenderer};
+use crate::rendering::text::{KinoTextRenderer, TextEntry};
 use crate::rendering::texture::Texture;
 use crate::rendering::vertex::Vertex;
 use crate::resp::WidgetResponse;
@@ -33,16 +33,21 @@ use std::collections::{HashMap, VecDeque};
 use std::fmt::Debug;
 use std::hash::{DefaultHasher, Hash, Hasher};
 use wgpu::{LoadOp, StoreOp};
+use dropbear_utils::StaleTracker;
 
 /// Holds the state of all the instructions, and the vertices+indices for rendering as well
 /// as the responses.
 pub struct KinoState {
     renderer: KinoWGPURenderer,
     windowing: KinoWinitWindowing,
+    current_render_target: Option<KinoRenderTargetId>,
+    render_targets: HashMap<KinoRenderTargetId, KinoRenderContext>,
+    render_target_cache: StaleTracker<KinoRenderTargetId, KinoRenderContext>,
+    batches: HashMap<KinoRenderTargetId, PrimitiveBatch>,
+    text_entries: HashMap<KinoRenderTargetId, Vec<TextEntry>>,
     instruction_set: VecDeque<UiInstructionType>,
     widget_states: HashMap<WidgetId, WidgetResponse>,
     assets: AssetServer,
-    batch: PrimitiveBatch,
     camera: Camera2D,
     container_stack: Vec<ContainerContext>,
 }
@@ -89,15 +94,227 @@ impl KinoState {
         KinoState {
             renderer,
             windowing,
+            current_render_target: None,
+            render_targets: Default::default(),
+            render_target_cache: Default::default(),
+            batches: Default::default(),
+            text_entries: Default::default(),
             instruction_set: Default::default(),
             widget_states: Default::default(),
             assets: Default::default(),
-            batch: Default::default(),
             camera: Camera2D::default(),
             container_stack: Default::default(),
         }
     }
 
+    /// Starts the pass for rendering kino ui widgets.
+    ///
+    /// This function panics if an existing render target has been started without [`self.flush`] not being called
+    /// and a new one is created.
+    ///
+    /// This belongs in your `update()` function at the start of the "render pass".
+    pub fn begin(&mut self, render_target: KinoRenderTargetId) {
+        if self.current_render_target.is_some() {
+            panic!("Tried to begin a new render target while a previous one is still active");
+        }
+
+        self.current_render_target = Some(render_target);
+    }
+
+    /// This finishes your kino render pass, flushes and builds the widget tree before being polled
+    /// for inputs and prepared for GPU upload.
+    ///
+    /// This function panics if this function is called without an existing render target.
+    ///
+    /// This belongs in your `update()` function at the end of the "render pass".
+    pub fn flush(&mut self) {
+        log::trace!("flushing kinostate");
+        let Some(render_target) = self.current_render_target else {
+            panic!("flush() called without an active render target; call begin() first");
+        };
+
+        let current_instructions = { self.instruction_set.drain(..).collect::<Vec<_>>() };
+
+        self.widget_states.clear();
+
+        let tree = Self::build_tree(current_instructions);
+
+        self.render_tree(tree);
+
+        self.render_targets
+            .entry(render_target)
+            .or_insert(KinoRenderContext::HUD);
+
+        self.current_render_target = None;
+    }
+
+    /// Returns a potential [`wgpu::TextureView`] from the render target cache (billboard) based on the provided entity_id.
+    pub fn billboard_render_target_view(&mut self, entity_id: u64) -> Option<&wgpu::TextureView> {
+        let target = KinoRenderTargetId::Billboard(entity_id);
+        match self.render_target_cache.get(&target) {
+            Some(KinoRenderContext::Billboard { view, .. }) => Some(view),
+            _ => None,
+        }
+    }
+
+    /// Collects all [`KinoRenderTargetId`] and [`wgpu::TextureView`] pairs from the render target cache.
+    pub fn billboard_render_target_views(&self) -> Vec<(u64, wgpu::TextureView)> {
+        self.render_target_cache
+            .iter()
+            .filter_map(|(target, context)| match (target, context) {
+                (KinoRenderTargetId::Billboard(entity_id), KinoRenderContext::Billboard { view, .. }) => {
+                    Some((*entity_id, view.clone()))
+                }
+                _ => None,
+            })
+            .collect()
+    }
+
+    pub(crate) fn push_primitive(
+        &mut self,
+        verts: &[Vertex],
+        indices: &[u16],
+        texture_id: Option<Handle<Texture>>,
+    ) {
+        let target = self
+            .current_render_target
+            .expect("Attempted to push primitives without begin(render_target)");
+        self.batches
+            .entry(target)
+            .or_default()
+            .push(verts, indices, texture_id);
+    }
+
+    pub(crate) fn push_text_entry(&mut self, entry: TextEntry) {
+        let target = self
+            .current_render_target
+            .expect("Attempted to push text without begin(render_target)");
+        self.text_entries.entry(target).or_default().push(entry);
+    }
+
+    fn ensure_billboard_target(
+        &mut self,
+        device: &wgpu::Device,
+        target: KinoRenderTargetId,
+        width: u32,
+        height: u32,
+    ) -> wgpu::TextureView {
+        let recreate = match self.render_target_cache.get(&target) {
+            Some(KinoRenderContext::Billboard { view, .. }) => {
+                let tex = view.texture();
+                tex.width() != width || tex.height() != height
+            }
+            _ => true,
+        };
+
+        if recreate {
+            let (texture, view) = Texture::create_render_target(
+                device,
+                self.renderer.texture_bind_group_layout(),
+                width,
+                height,
+                self.renderer.format,
+            );
+
+            self.render_target_cache
+                .insert(target, KinoRenderContext::Billboard { texture, view });
+        }
+
+        match self.render_target_cache.get(&target) {
+            Some(KinoRenderContext::Billboard { view, .. }) => view.clone(),
+            _ => unreachable!("Billboard render target cache entry must be Billboard"),
+        }
+    }
+
+    fn used_extent(
+        geometry: &[TexturedGeometry],
+        text_entries: &[TextEntry],
+    ) -> (u32, u32) {
+        let mut max_extent = Vec2::ZERO;
+
+        for textured in geometry {
+            if let Some(max_pos) = textured.batch.max_position() {
+                max_extent = max_extent.max(max_pos);
+            }
+        }
+
+        for entry in text_entries {
+            max_extent = max_extent.max(entry.position + entry.size);
+        }
+
+        let width = max_extent.x.ceil().max(1.0) as u32;
+        let height = max_extent.y.ceil().max(1.0) as u32;
+        (width, height)
+    }
+
+    /// Renders all billboard targets into their allocated textures.
+    pub fn render_billboard_targets(
+        &mut self,
+        device: &wgpu::Device,
+        queue: &wgpu::Queue,
+        encoder: &mut wgpu::CommandEncoder,
+    ) {
+        self.render_target_cache.tick();
+
+        let targets = self
+            .render_targets
+            .keys()
+            .copied()
+            .filter(|target| {
+                matches!(target, KinoRenderTargetId::Billboard(_))
+                    && (self.batches.contains_key(target) || self.text_entries.contains_key(target))
+            })
+            .collect::<Vec<_>>();
+
+        for target in targets {
+            let mut geometry = self
+                .batches
+                .remove(&target)
+                .map(|mut batch| batch.take())
+                .unwrap_or_default();
+            let text_entries = self.text_entries.remove(&target).unwrap_or_default();
+
+            if geometry.is_empty() && text_entries.is_empty() {
+                continue;
+            }
+
+            let (width, height) = Self::used_extent(&geometry, &text_entries);
+            let target_view = self.ensure_billboard_target(device, target, width, height);
+            self.renderer.upload_camera_matrix(
+                queue,
+                self.camera
+                    .view_proj(Vec2::new(width as f32, height as f32))
+                    .to_cols_array_2d(),
+            );
+            self.renderer.text.entries = text_entries;
+            self.renderer.text.prepare(device, queue, width, height);
+
+            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+                label: Some("kino billboard render pass"),
+                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+                    view: &target_view,
+                    depth_slice: None,
+                    resolve_target: None,
+                    ops: wgpu::Operations {
+                        load: LoadOp::Clear(wgpu::Color::TRANSPARENT),
+                        store: StoreOp::Store,
+                    },
+                })],
+                depth_stencil_attachment: None,
+                timestamp_writes: None,
+                occlusion_query_set: None,
+            });
+
+            for mut tg in geometry.drain(..) {
+                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);
+            }
+
+            self.renderer.text.render(&mut pass);
+        }
+    }
+
     /// Adds a widget (a [`NativeWidget`]) to the instruction set as a
     /// [`UiInstructionType::Widget`] and returns back the associated [`WidgetId`] for response
     /// checking.
@@ -136,29 +353,9 @@ impl KinoState {
         self.instruction_set.push_back(ui_instruction_type);
     }
 
-    /// Polls for changes by clearing the current instruction set, build the tree and
-    /// preparing them for rendering.
-    ///
-    /// If you create a widget and then check for a response before polling, you will not receive
-    /// back a response. You are required to poll/prepare the contents before being given access
-    /// to the response information.
-    ///
-    /// 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);
-    }
-
     /// Fetches the [`WidgetResponse`] from an associated [`WidgetId`].
     ///
-    /// This will only provide the proper information **after** you have
-    /// polled with [`KinoState::poll`].
+    /// Returns a default [`WidgetResponse`] if not available.
     pub fn response(&self, id: impl Into<WidgetId>) -> WidgetResponse {
         self.widget_states
             .get(&id.into())
@@ -176,7 +373,7 @@ impl KinoState {
         self.windowing.viewport_scale = scale;
     }
 
-    /// Pushes the vertices and indices to the renderer.
+    /// Pushes the vertices and indices to the renderer and renders into an allocated view.
     ///
     /// This is the recommended `render()` function and is used when you want
     /// `kino_ui` to create the render pass and submit to the queue.
@@ -194,18 +391,28 @@ impl KinoState {
             queue,
             self.camera.view_proj(self.renderer.size).to_cols_array_2d(),
         );
-        let batch = self.batch.take();
+        self.render_target_cache.tick();
+
+        let mut hud_geometry = self
+            .batches
+            .remove(&KinoRenderTargetId::HUD)
+            .map(|mut batch| batch.take())
+            .unwrap_or_default();
+        self.renderer.text.entries = self
+            .text_entries
+            .remove(&KinoRenderTargetId::HUD)
+            .unwrap_or_default();
 
         let (width, height) = {
             let tex = view.texture();
             (tex.width(), tex.height())
         };
 
-        self.renderer.text.prepare(&device, &queue, width, height);
+        self.renderer.text.prepare(device, queue, width, height);
 
         {
             let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
-                label: Some("kino render pass"),
+                label: Some("kino hud render pass"),
                 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                     view,
                     depth_slice: None,
@@ -220,8 +427,7 @@ impl KinoState {
                 occlusion_query_set: None,
             });
 
-            for mut tg in batch {
-                // log::debug!("Rendering textured geometry: {:?}", tg);
+            for mut tg in hud_geometry.drain(..) {
                 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);
@@ -247,7 +453,15 @@ impl KinoState {
             queue,
             self.camera.view_proj(self.renderer.size).to_cols_array_2d(),
         );
-        let batch = self.batch.take();
+        let batch = self
+            .batches
+            .remove(&KinoRenderTargetId::HUD)
+            .map(|mut batch| batch.take())
+            .unwrap_or_default();
+        self.renderer.text.entries = self
+            .text_entries
+            .remove(&KinoRenderTargetId::HUD)
+            .unwrap_or_default();
 
         for mut tg in batch {
             // log::debug!("Rendering textured geometry: {:?}", tg);
@@ -528,7 +742,7 @@ pub struct TexturedGeometry {
 }
 
 #[derive(Default)]
-pub struct PrimitiveBatch {
+pub struct PrimitiveBatch{
     geometry: Vec<TexturedGeometry>,
 }
 
diff --git a/crates/kino-ui/src/rendering.rs b/crates/kino-ui/src/rendering.rs
index f8ce09a..f49b3c9 100644
--- a/crates/kino-ui/src/rendering.rs
+++ b/crates/kino-ui/src/rendering.rs
@@ -119,3 +119,17 @@ impl KinoWGPURenderer {
         &self.pipeline.texture_bind_group_layout
     }
 }
+
+pub enum KinoRenderContext {
+    HUD,
+    Billboard {
+        texture: texture::Texture,
+        view: wgpu::TextureView,
+    }
+}
+
+#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq)]
+pub enum KinoRenderTargetId {
+    HUD,
+    Billboard(u64),
+}
diff --git a/crates/kino-ui/src/rendering/batching.rs b/crates/kino-ui/src/rendering/batching.rs
index 518df04..16fe293 100644
--- a/crates/kino-ui/src/rendering/batching.rs
+++ b/crates/kino-ui/src/rendering/batching.rs
@@ -1,4 +1,5 @@
 use crate::rendering::vertex::Vertex;
+use glam::Vec2;
 use wgpu::{BufferUsages, IndexFormat};
 
 /// Describes a primitive shape.
@@ -117,4 +118,14 @@ impl VertexBatch {
         );
         pass.draw_indexed(0..self.indices.len() as u32, 0, 0..1);
     }
+
+    pub fn max_position(&self) -> Option<Vec2> {
+        self.vertices.iter().fold(None, |acc, vertex| {
+            let pos = Vec2::new(vertex.position[0], vertex.position[1]);
+            Some(match acc {
+                Some(current) => current.max(pos),
+                None => pos,
+            })
+        })
+    }
 }
diff --git a/crates/kino-ui/src/rendering/text.rs b/crates/kino-ui/src/rendering/text.rs
index 31c1bb5..b6f992b 100644
--- a/crates/kino-ui/src/rendering/text.rs
+++ b/crates/kino-ui/src/rendering/text.rs
@@ -7,6 +7,7 @@ use glyphon::{
 pub struct TextEntry {
     pub buffer: Buffer,
     pub position: Vec2,
+    pub size: Vec2,
 }
 
 pub struct KinoTextRenderer {
@@ -56,10 +57,6 @@ impl KinoTextRenderer {
     /// Prepare the text renderer for drawing
     pub fn prepare(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, width: u32, height: u32) {
         self.viewport.update(queue, Resolution { width, height });
-        if self.entries.is_empty() {
-            return;
-        }
-
         let text_areas: Vec<TextArea> = self
             .entries
             .iter()
diff --git a/crates/kino-ui/src/rendering/texture.rs b/crates/kino-ui/src/rendering/texture.rs
index e679b77..14b977d 100644
--- a/crates/kino-ui/src/rendering/texture.rs
+++ b/crates/kino-ui/src/rendering/texture.rs
@@ -144,6 +144,54 @@ impl Texture {
         )
     }
 
+    pub fn create_render_target(
+        device: &Device,
+        bind_group_layout: &BindGroupLayout,
+        width: u32,
+        height: u32,
+        texture_format: TextureFormat,
+    ) -> (Self, wgpu::TextureView) {
+        let texture = device.create_texture(&TextureDescriptor {
+            label: Some("kino billboard render target"),
+            size: Extent3d {
+                width,
+                height,
+                depth_or_array_layers: 1,
+            },
+            mip_level_count: 1,
+            sample_count: 1,
+            dimension: TextureDimension::D2,
+            format: texture_format,
+            usage: TextureUsages::TEXTURE_BINDING | TextureUsages::RENDER_ATTACHMENT,
+            view_formats: &[],
+        });
+
+        let view = texture.create_view(&Default::default());
+        let sampler = device.create_sampler(&Default::default());
+        let bind_group = device.create_bind_group(&BindGroupDescriptor {
+            label: Some("kino billboard render target bind group"),
+            layout: bind_group_layout,
+            entries: &[
+                BindGroupEntry {
+                    binding: 0,
+                    resource: BindingResource::TextureView(&view),
+                },
+                BindGroupEntry {
+                    binding: 1,
+                    resource: BindingResource::Sampler(&sampler),
+                },
+            ],
+        });
+
+        (
+            Self {
+                hash: 0,
+                bind_group,
+            },
+            view,
+        )
+    }
+
     /// Binds this texture at the given index in the render pass
     ///
     /// - `index` must match the bind group index used in the pipeline layout
diff --git a/crates/kino-ui/src/widgets/rect.rs b/crates/kino-ui/src/widgets/rect.rs
index 434d3a8..d3fd8da 100644
--- a/crates/kino-ui/src/widgets/rect.rs
+++ b/crates/kino-ui/src/widgets/rect.rs
@@ -177,9 +177,7 @@ impl Rectangle {
             })
             .collect();
 
-        state
-            .batch
-            .push(&fill_verts, &[0, 1, 2, 2, 3, 0], self.texture);
+        state.push_primitive(&fill_verts, &[0, 1, 2, 2, 3, 0], self.texture);
 
         if let Some(border) = self.border {
             let half_width = border.width / 2.0;
@@ -216,7 +214,7 @@ impl Rectangle {
                 0, 1, 3, 3, 2, 0, 2, 3, 5, 5, 4, 2, 4, 5, 7, 7, 6, 4, 6, 7, 1, 1, 0, 6,
             ];
 
-            state.batch.push(&border_verts, &border_indices, None);
+            state.push_primitive(&border_verts, &border_indices, None);
         }
     }
 }
diff --git a/crates/kino-ui/src/widgets/text.rs b/crates/kino-ui/src/widgets/text.rs
index f7004dd..3980963 100644
--- a/crates/kino-ui/src/widgets/text.rs
+++ b/crates/kino-ui/src/widgets/text.rs
@@ -116,9 +116,10 @@ impl NativeWidget for Text {
             },
         );
 
-        state.renderer.text.entries.push(TextEntry {
+        state.push_text_entry(TextEntry {
             buffer,
             position: top_left,
+            size,
         });
     }
 
diff --git a/crates/redback-runtime/src/lib.rs b/crates/redback-runtime/src/lib.rs
index 6074742..f5f22de 100644
--- a/crates/redback-runtime/src/lib.rs
+++ b/crates/redback-runtime/src/lib.rs
@@ -40,6 +40,7 @@ use std::sync::Arc;
 use wgpu::SurfaceConfiguration;
 use wgpu::util::DeviceExt;
 use winit::window::Fullscreen;
+use dropbear_engine::billboarding::BillboardPipeline;
 
 mod command;
 mod input;
@@ -145,6 +146,7 @@ pub struct PlayMode {
     default_morph_weights_buffer: Option<wgpu::Buffer>,
     default_morph_info_buffer: Option<wgpu::Buffer>,
     default_animation_bind_group: Option<wgpu::BindGroup>,
+    billboard_pipeline: Option<BillboardPipeline>,
 
     initial_scene: Option<String>,
     current_scene: Option<String>,
@@ -171,7 +173,6 @@ pub struct PlayMode {
     viewport_offset: (f32, f32),
 
     // ui
-    // yakui_winit: Option<YakuiWinit>,
     kino: Option<kino_ui::KinoState>,
 }
 
@@ -239,6 +240,7 @@ impl PlayMode {
             default_morph_weights_buffer: None,
             default_morph_info_buffer: None,
             default_animation_bind_group: None,
+            billboard_pipeline: None,
         };
 
         log::debug!("Created new play mode instance");
@@ -365,6 +367,8 @@ impl PlayMode {
             KinoWinitWindowing::new(graphics.window.clone(), None),
         ));
 
+        self.billboard_pipeline = Some(BillboardPipeline::new(graphics.clone()));
+
         let sky_texture_result = HdrLoader::from_equirectangular_bytes(
             &graphics.device,
             &graphics.queue,
diff --git a/crates/redback-runtime/src/scene.rs b/crates/redback-runtime/src/scene.rs
index 094092e..abff776 100644
--- a/crates/redback-runtime/src/scene.rs
+++ b/crates/redback-runtime/src/scene.rs
@@ -12,6 +12,7 @@ use dropbear_engine::lighting::Light;
 use dropbear_engine::model::{DrawLight, DrawModel};
 use dropbear_engine::pipelines::DropbearShaderPipeline;
 use dropbear_engine::scene::{Scene, SceneCommand};
+use eucalyptus_core::billboard::BillboardComponent;
 use eucalyptus_core::command::CommandBufferPoller;
 use eucalyptus_core::egui::CentralPanel;
 use eucalyptus_core::hierarchy::{EntityTransformExt, Parent};
@@ -25,13 +26,16 @@ use eucalyptus_core::rapier3d::prelude::QueryFilter;
 use eucalyptus_core::scene::loading::{IsSceneLoaded, SCENE_LOADER, SceneLoadResult};
 use eucalyptus_core::states::SCENES;
 use eucalyptus_core::states::{Label, PROJECT};
-use glam::{DVec3, Quat, Vec2};
+use glam::{vec2, DVec3, Mat3, Mat4, Quat, Vec2, Vec3};
 use hecs::Entity;
 // use kino_ui::widgets::rect::Rectangle;
 // use kino_ui::widgets::{Border, Fill};
 use std::collections::HashMap;
 use winit::event::WindowEvent;
 use winit::event_loop::ActiveEventLoop;
+use kino_ui::rendering::KinoRenderTargetId;
+use kino_ui::widgets::{Border, Fill};
+use kino_ui::widgets::rect::Rectangle;
 
 impl Scene for PlayMode {
     fn load(&mut self, graphics: Arc<SharedGraphicsContext>) {
@@ -61,16 +65,6 @@ impl Scene for PlayMode {
                 .physics_update_script(self.world.as_mut(), dt as f64);
         }
 
-        let world = self
-            .world
-            .iter()
-            .map(|e| (e.get::<&Label>().unwrap().to_string(), e.entity()))
-            .collect::<Vec<_>>();
-        log::info!("World contents [len={}]: ", world.len());
-        for (l, e) in world {
-            log::info!("{} -> {:?}", l, e);
-        }
-
         for kcc in self.world.query::<&mut KCC>().iter() {
             kcc.collisions.clear();
         }
@@ -552,6 +546,57 @@ impl Scene for PlayMode {
                         }));
                     });
 
+                    if let Some(kino) = &mut self.kino {
+                        // kino.begin(KinoRenderTargetId::HUD);
+                        kino.begin(KinoRenderTargetId::Billboard(0));
+
+                        // todo: clean this up after testing
+                        let no_texture = kino.add_texture_from_bytes(
+                            &graphics.device,
+                            &graphics.queue,
+                            "no texture",
+                            include_bytes!("../../../resources/textures/no-texture.png"),
+                            256,
+                            256,
+                        );
+
+                        kino_ui::rect_container(
+                            kino,
+                            Rectangle::new("parent")
+                                .fill(Fill::new([1.0, 1.0, 1.0, 1.0]))
+                                .size(vec2(400.0, 400.0)),
+                            |kino| {
+                                kino.add_widget(
+                                    Rectangle::new("rect")
+                                        .texture(no_texture)
+                                        .size(vec2(128.0, 100.0))
+                                        .border(Border::new([1.0, 0.0, 0.0, 1.0], 3.0))
+                                        .fill(Fill::new([1.0, 1.0, 1.0, 1.0]))
+                                        .texture(no_texture)
+                                        .build(),
+                                );
+                            },
+                        );
+
+                        kino_ui::label(kino, "Hello World!", |l| {
+                            l.position = vec2(
+                                graphics.viewport_texture.size.width as f32 / 2.0,
+                                graphics.viewport_texture.size.height as f32 / 2.0,
+                            );
+                            l.metrics.font_size = 30.0;
+                        });
+
+                        kino.flush();
+
+                        kino.begin(KinoRenderTargetId::HUD);
+
+                        kino_ui::rect(kino, "rect", |rect| {
+                            rect.fill = Fill::new([1.0, 0.0, 0.0, 1.0]);
+                        });
+
+                        kino.flush();
+                    }
+
                     // if let Some(kino) = &mut self.kino {
                     //     // #[allow(dead_code)]
                     //     let no_texture = kino.add_texture_from_bytes(
@@ -1107,6 +1152,7 @@ impl Scene for PlayMode {
             render_pass.draw(0..3, 0..1);
         }
 
+        // collider wireframe hitbox renderer
         {
             let show_hitboxes = self
                 .current_scene
@@ -1232,7 +1278,123 @@ impl Scene for PlayMode {
             }
         }
 
-        hdr.process(&mut encoder, &graphics.viewport_texture.view);
+        // kino billboard renderer
+        {
+            // 1. render billboards and prepare views
+            if let Some(kino) = &mut self.kino {
+                let mut kino_encoder = CommandEncoder::new(graphics.clone(), Some("kino billboard encoder"));
+                kino.render_billboard_targets(
+                    &graphics.device,
+                    &graphics.queue,
+                    &mut kino_encoder,
+                );
+
+                if let Err(e) = kino_encoder.submit() {
+                    log_once::error_once!("Unable to submit billboard kino pass: {}", e);
+                }
+            }
+
+            // 2. prepare billboards for application to viewport
+            if let Some(billboard_pipeline) = &self.billboard_pipeline {
+                let camera_position = camera.position().as_vec3();
+                let camera_projection = Mat4::from_cols_array_2d(&camera.uniform.view_proj);
+
+                let mut kino_views = HashMap::<u64, wgpu::TextureView>::new();
+                if let Some(kino) = &mut self.kino {
+                    kino_views.extend(kino.billboard_render_target_views());
+                }
+
+                let single_fallback_view = if kino_views.len() == 1 {
+                    kino_views.values().next().cloned()
+                } else {
+                    None
+                };
+
+                let mut billboards: Vec<(Mat4, wgpu::TextureView)> = Vec::new();
+                let mut query = self
+                    .world
+                    .query::<(Entity, &BillboardComponent, &EntityTransform)>();
+                for (entity, billboard, entity_transform) in query.iter()
+                {
+                    if !billboard.enabled {
+                        continue;
+                    }
+
+                    let entity_id = entity.to_bits().get();
+                    let texture_view = kino_views
+                        .get(&entity_id)
+                        .cloned()
+                        .or_else(|| single_fallback_view.clone());
+
+                    let Some(texture_view) = texture_view else {
+                        continue;
+                    };
+
+                    let world_transform = entity_transform.sync();
+                    let position = world_transform.position.as_vec3() + billboard.offset;
+                    let world_size = billboard.world_size;
+                    let scale = Vec3::new(world_size.x, world_size.y, 1.0);
+
+                    let rotation = if let Some(rotation) = billboard.rotation {
+                        rotation
+                    } else {
+                        let to_camera = (camera_position - position).normalize_or_zero();
+                        if to_camera.length_squared() > 0.0 {
+                            let mut world_up = Vec3::Y;
+                            if to_camera.dot(world_up).abs() > 0.999 {
+                                world_up = Vec3::X;
+                            }
+
+                            let right = world_up.cross(to_camera).normalize_or_zero();
+                            let up = to_camera.cross(right).normalize_or_zero();
+                            let basis = Mat3::from_cols(right, up, to_camera);
+                            Quat::from_mat3(&basis)
+                        } else {
+                            Quat::IDENTITY
+                        }
+                    };
+
+                    let transform = Mat4::from_scale_rotation_translation(scale, rotation, position);
+                    billboards.push((transform, texture_view));
+                }
+
+                if !billboards.is_empty() {
+                    let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+                        label: Some("runtime billboard render pass"),
+                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+                            view: hdr.render_view(),
+                            depth_slice: None,
+                            resolve_target: hdr.resolve_target(),
+                            ops: wgpu::Operations {
+                                load: wgpu::LoadOp::Load,
+                                store: wgpu::StoreOp::Store,
+                            },
+                        })],
+                        depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
+                            view: &graphics.depth_texture.view,
+                            depth_ops: Some(wgpu::Operations {
+                                load: wgpu::LoadOp::Load,
+                                store: wgpu::StoreOp::Store,
+                            }),
+                            stencil_ops: None,
+                        }),
+                        timestamp_writes: None,
+                        occlusion_query_set: None,
+                    });
+
+                    for (transform, texture_view) in billboards {
+                        // i mean technically it *is* versatile in the sense that you can use other views 🤷
+                        billboard_pipeline.draw(
+                            graphics.clone(),
+                            &mut render_pass,
+                            transform,
+                            camera_projection,
+                            &texture_view,
+                        );
+                    }
+                }
+            }
+        }
 
         if let Err(e) = encoder.submit() {
             log_once::error_once!("{}", e);
@@ -1251,14 +1413,6 @@ impl Scene for PlayMode {
     fn exit(&mut self, _event_loop: &ActiveEventLoop) {}
 
     fn handle_event(&mut self, event: &WindowEvent) {
-        // UI_CONTEXT.with(|yakui_cell| {
-        //     let yak = yakui_cell.borrow();
-        //     let mut yakui = yak.yakui_state.lock();
-        //     if let Some(yak) = &mut self.yakui_winit {
-        //         yak.handle_window_event(&mut yakui, event);
-        //     }
-        // });
-
         if let Some(kino) = &mut self.kino {
             kino.handle_event(event);
         }