kitgit

tirbofish/dropbear · commit

a75bf7707079ec6c32154552c01b1fde793aa163

feature: debug drawing, such as rays and objects and lines and stuff like that todo: change colliderwireframe to use DebugDraw instead.

Unverified

Thribhu K <4tkbytes@pm.me> · 2026-03-26 13:59

view full diff

diff --git a/crates/dropbear-engine/src/buffer.rs b/crates/dropbear-engine/src/buffer.rs
index 8640d4b..cc76cad 100644
--- a/crates/dropbear-engine/src/buffer.rs
+++ b/crates/dropbear-engine/src/buffer.rs
@@ -11,66 +11,87 @@ pub struct ResizableBuffer<T> {
     _marker: PhantomData<T>,
 }
 
-#[derive(Debug, Clone)]
-pub struct UniformBuffer<T> {
-    buffer: wgpu::Buffer,
-    label: String,
-    _marker: PhantomData<T>,
-}
-
-impl<T: bytemuck::Pod> UniformBuffer<T> {
-    pub fn new(device: &wgpu::Device, label: &str) -> Self {
-        let size = (std::mem::size_of::<T>() as wgpu::BufferAddress).max(16);
+impl<T: bytemuck::Pod> ResizableBuffer<T> {
+    pub fn new(
+        device: &wgpu::Device,
+        initial_capacity: usize,
+        usage: wgpu::BufferUsages,
+        label: &str,
+    ) -> Self {
+        let size = (initial_capacity * std::mem::size_of::<T>()) as wgpu::BufferAddress;
         let buffer = device.create_buffer(&wgpu::BufferDescriptor {
             label: Some(label),
-            size,
-            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+            size: size.max(16),
+            usage,
             mapped_at_creation: false,
         });
 
+        log::debug!("Registered new resizable buffer: {:?} (usage={:?})", label, usage);
         Self {
             buffer,
+            capacity: initial_capacity,
+            usage,
             label: label.to_string(),
             _marker: PhantomData,
         }
     }
 
-    pub fn write(&self, queue: &wgpu::Queue, value: &T) {
+    pub fn write(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, data: &[T]) {
         puffin::profile_function!(&self.label);
-        queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(value));
+        if data.is_empty() {
+            return;
+        }
+
+        if data.len() > self.capacity {
+            self.capacity = data.len().max(self.capacity * 2);
+
+            let new_size = (self.capacity * std::mem::size_of::<T>()) as wgpu::BufferAddress;
+
+            log::debug!(
+                "Resizing buffer '{}' to hold {} items",
+                self.label,
+                self.capacity
+            );
+
+            self.buffer = device.create_buffer(&wgpu::BufferDescriptor {
+                label: Some(&self.label),
+                size: new_size,
+                usage: self.usage,
+                mapped_at_creation: false,
+            });
+        }
+
+        queue.write_buffer(&self.buffer, 0, bytemuck::cast_slice(data));
     }
 
     pub fn buffer(&self) -> &wgpu::Buffer {
         &self.buffer
     }
 
-    pub fn label(&self) -> &str {
-        &self.label
+    pub fn slice(&self, count: usize) -> wgpu::BufferSlice<'_> {
+        let byte_count = (count * std::mem::size_of::<T>()) as wgpu::BufferAddress;
+        self.buffer.slice(0..byte_count)
     }
 }
 
-/// A wrapper to a [wgpu::Buffer] that stores
 #[derive(Debug, Clone)]
-pub struct StorageBuffer<T> {
+pub struct UniformBuffer<T> {
     buffer: wgpu::Buffer,
     label: String,
     _marker: PhantomData<T>,
 }
 
-impl<T: bytemuck::Pod> StorageBuffer<T> {
-    /// Creates a storage buffer intended to be written by the CPU and read by the GPU.
-    ///
-    /// Note: whether it is bound as read-only is controlled by the bind group layout
-    /// (`BufferBindingType::Storage { read_only: true }`).
+impl<T: bytemuck::Pod> UniformBuffer<T> {
     pub fn new(device: &wgpu::Device, label: &str) -> Self {
         let size = (std::mem::size_of::<T>() as wgpu::BufferAddress).max(16);
         let buffer = device.create_buffer(&wgpu::BufferDescriptor {
             label: Some(label),
             size,
-            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
+            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
             mapped_at_creation: false,
         });
 
+        log::debug!("Registered new uniform buffer: {:?}", label);
         Self {
             buffer,
             label: label.to_string(),
@@ -79,7 +100,7 @@ impl<T: bytemuck::Pod> StorageBuffer<T> {
     }
 
     pub fn write(&self, queue: &wgpu::Queue, value: &T) {
-        puffin::profile_function!(self.label());
+        puffin::profile_function!(&self.label);
         queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(value));
     }
 
@@ -92,64 +113,56 @@ impl<T: bytemuck::Pod> StorageBuffer<T> {
     }
 }
 
-impl<T: bytemuck::Pod> ResizableBuffer<T> {
-    pub fn new(
-        device: &wgpu::Device,
-        initial_capacity: usize,
-        usage: wgpu::BufferUsages,
-        label: &str,
-    ) -> Self {
-        let size = (initial_capacity * std::mem::size_of::<T>()) as wgpu::BufferAddress;
+/// A wrapper to a [wgpu::Buffer] that stores
+#[derive(Debug, Clone)]
+pub struct StorageBuffer<T> {
+    buffer: wgpu::Buffer,
+    label: String,
+    _marker: PhantomData<T>,
+}
+
+impl<T: bytemuck::Pod> StorageBuffer<T> {
+    pub fn new_read_only(device: &wgpu::Device, label: &str) -> Self {
+        Self::new(device, label, true)
+    }
+
+    pub fn new_read_write(device: &wgpu::Device, label: &str) -> Self {
+        Self::new(device, label, false)
+    }
+
+    fn new(device: &wgpu::Device, label: &str, read_only: bool) -> Self {
+        let usage = if read_only {
+            wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST
+        } else {
+            wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC
+        };
+
+        let size = (std::mem::size_of::<T>() as wgpu::BufferAddress).max(16);
         let buffer = device.create_buffer(&wgpu::BufferDescriptor {
             label: Some(label),
-            size: size.max(16),
+            size,
             usage,
             mapped_at_creation: false,
         });
 
+        log::debug!("Registered new storage buffer: {:?} (read_only: {})", label, read_only);
         Self {
             buffer,
-            capacity: initial_capacity,
-            usage,
             label: label.to_string(),
             _marker: PhantomData,
         }
     }
 
-    pub fn write(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, data: &[T]) {
-        puffin::profile_function!(&self.label);
-        if data.is_empty() {
-            return;
-        }
-
-        if data.len() > self.capacity {
-            self.capacity = data.len().max(self.capacity * 2);
-
-            let new_size = (self.capacity * std::mem::size_of::<T>()) as wgpu::BufferAddress;
-
-            log::debug!(
-                "Resizing buffer '{}' to hold {} items",
-                self.label,
-                self.capacity
-            );
-
-            self.buffer = device.create_buffer(&wgpu::BufferDescriptor {
-                label: Some(&self.label),
-                size: new_size,
-                usage: self.usage,
-                mapped_at_creation: false,
-            });
-        }
-
-        queue.write_buffer(&self.buffer, 0, bytemuck::cast_slice(data));
+    pub fn write(&self, queue: &wgpu::Queue, value: &T) {
+        puffin::profile_function!(self.label());
+        queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(value));
     }
 
     pub fn buffer(&self) -> &wgpu::Buffer {
         &self.buffer
     }
 
-    pub fn slice(&self, count: usize) -> wgpu::BufferSlice<'_> {
-        let byte_count = (count * std::mem::size_of::<T>()) as wgpu::BufferAddress;
-        self.buffer.slice(0..byte_count)
+    pub fn label(&self) -> &str {
+        &self.label
     }
-}
+}
\ No newline at end of file
diff --git a/crates/dropbear-engine/src/debug.rs b/crates/dropbear-engine/src/debug.rs
index e38f424..fb63808 100644
--- a/crates/dropbear-engine/src/debug.rs
+++ b/crates/dropbear-engine/src/debug.rs
@@ -1,59 +1,519 @@
 use std::sync::Arc;
-use wgpu::{RenderPipeline, RenderPipelineDescriptor, VertexState};
-use crate::graphics::SharedGraphicsContext;
-
-pub struct DebugLine {
-
-}
+use glam::{Mat4, Quat, Vec3, Vec4};
+use wgpu::{BindGroupDescriptor, BindGroupEntry, BindGroupLayoutDescriptor, BindGroupLayoutEntry, BindingResource, BindingType, BufferBindingType, BufferUsages, CompareFunction, DepthStencilState, LoadOp, MultisampleState, Operations, PrimitiveState, PrimitiveTopology, RenderPassColorAttachment, RenderPassDepthStencilAttachment, RenderPassDescriptor, RenderPipeline, RenderPipelineDescriptor, ShaderStages, StoreOp, TextureFormat, VertexBufferLayout, VertexState};
+use crate::buffer::{ResizableBuffer, UniformBuffer};
+use crate::graphics::{CommandEncoder, SharedGraphicsContext};
+use crate::shader::Shader;
 
 pub struct DebugDraw {
     pipeline: Arc<DebugDrawPipeline>,
+    vertices: Vec<DebugVertex>,
+    vertex_buffer: ResizableBuffer<DebugVertex>,
 }
 
+// main parts
 impl DebugDraw {
-    pub fn draw_line(&self) {
+    /// Creates a new [`DebugDraw`] instance, setting up all `wgpu` related structs such as pipelines
+    /// and buffers.
+    pub fn new(graphics: Arc<SharedGraphicsContext>) -> Self {
+        let pipeline = Arc::new(DebugDrawPipeline::new(graphics.clone()));
+        let vertices = vec![];
+        let vertex_buffer = ResizableBuffer::new(
+            &graphics.device,
+            1024,
+            BufferUsages::VERTEX | BufferUsages::COPY_DST,
+            "debug draw vertex buffer"
+        );
+
+        Self {
+            pipeline,
+            vertices,
+            vertex_buffer,
+        }
+    }
+
+    /// Flushes away all of the vertices to be drawn, and renders them at that instant.
+    pub fn flush(&mut self, graphics: Arc<SharedGraphicsContext>, encoder: &mut CommandEncoder, view_proj: Mat4) {
+        if self.vertices.is_empty() {
+            return;
+        }
+
+        self.vertex_buffer.write(
+            &graphics.device,
+            &graphics.queue,
+            bytemuck::cast_slice(&self.vertices)
+        );
 
+        self.pipeline.draw(
+            graphics,
+            encoder,
+            view_proj,
+            &self.vertex_buffer,
+            self.vertices.len() as u32,
+        );
+
+        self.vertices.clear();
     }
 }
 
+// helpers
+impl DebugDraw {
+    // primitives
+    /// Draws a line between 2 points with a specific colour. Probably the most primitive of them all.
+    pub fn draw_line(&mut self, a: Vec3, b: Vec3, colour: [f32; 4]) {
+        let a = a.to_array();
+        let b = b.to_array();
+        self.vertices.push(DebugVertex { position: [a[0], a[1], a[2], 0.0], colour });
+        self.vertices.push(DebugVertex { position: [b[0], b[1], b[2], 0.0], colour });
+    }
+
+    /// A wrapper for [draw_line](Self::draw_line), which draws a line from an origin and at a direction
+    /// with a specific colour. 
+    pub fn draw_ray(&mut self, origin: Vec3, dir: Vec3, colour: [f32; 4]) {
+        self.draw_line(origin, origin + dir, colour);
+    }
+
+    /// Draws a line from `a` to `b` with a specific colour and an arrowhead at the end.
+    pub fn draw_arrow(&mut self, a: Vec3, b: Vec3, colour: [f32; 4]) {
+        self.draw_line(a, b, colour);
+
+        // arrowhead — two short lines branching back from the tip
+        let dir = (b - a).normalize();
+        let len = (b - a).length() * 0.15;
+
+        // find a perpendicular axis
+        let up = if dir.dot(Vec3::Y).abs() < 0.99 { Vec3::Y } else { Vec3::Z };
+        let right = dir.cross(up).normalize();
+
+        let tip = b;
+        let base = tip - dir * len;
+        self.draw_line(tip, base + right * len * 0.5, colour);
+        self.draw_line(tip, base - right * len * 0.5, colour);
+    }
+
+    /// Draws a cross/asterisk at `pos` with the given `size`.
+    pub fn draw_point(&mut self, pos: Vec3, size: f32, colour: [f32; 4]) {
+        let h = size * 0.5;
+        self.draw_line(pos - Vec3::X * h, pos + Vec3::X * h, colour);
+        self.draw_line(pos - Vec3::Y * h, pos + Vec3::Y * h, colour);
+        self.draw_line(pos - Vec3::Z * h, pos + Vec3::Z * h, colour);
+    }
+
+    // shapes
+    /// Draws a circle in 3D space at `center` with the given `radius`.
+    ///
+    /// `normal` defines the axis the circle faces. e.g. `Vec3::Y` for a flat ground circle.
+    pub fn draw_circle(&mut self, center: Vec3, radius: f32, normal: Vec3, colour: [f32; 4]) {
+        let segments = 32;
+
+        // build tangent frame from normal
+        let up = if normal.dot(Vec3::Y).abs() < 0.99 { Vec3::Y } else { Vec3::Z };
+        let tangent = normal.cross(up).normalize();
+        let bitangent = normal.cross(tangent).normalize();
+
+        let mut prev = center + tangent * radius;
+        for i in 1..=segments {
+            let angle = (i as f32 / segments as f32) * std::f32::consts::TAU;
+            let next = center + (tangent * angle.cos() + bitangent * angle.sin()) * radius;
+            self.draw_line(prev, next, colour);
+            prev = next;
+        }
+    }
+
+    /// Draws 3 circles at [`Vec3::X`], [`Vec3::Y`], and [`Vec3::Z`] to make an imitation of a sphere.
+    ///
+    /// To see a proper sphere, use [`draw_globe`](Self::draw_globe).
+    pub fn draw_sphere(&mut self, center: Vec3, radius: f32, colour: [f32; 4]) {
+        self.draw_circle(center, radius, Vec3::X, colour);
+        self.draw_circle(center, radius, Vec3::Y, colour);
+        self.draw_circle(center, radius, Vec3::Z, colour);
+    }
+
+    /// Draws a wireframe sphere using latitude and longitude lines, giving a globe-like appearance.
+    ///
+    /// `lat_lines` controls the number of horizontal rings (latitude),
+    /// `lon_lines` controls the number of vertical rings (longitude).
+    pub fn draw_globe(&mut self, center: Vec3, radius: f32, lat_lines: u32, lon_lines: u32, color: [f32; 4]) {
+        // latitude rings (horizontal circles stacked along Y axis)
+        for i in 1..lat_lines {
+            let angle = std::f32::consts::PI * (i as f32 / lat_lines as f32); // 0..PI
+            let y = angle.cos() * radius;
+            let ring_radius = angle.sin() * radius;
+            self.draw_circle(center + Vec3::Y * y, ring_radius, Vec3::Y, color);
+        }
+
+        // longitude rings (vertical circles rotating around Y axis)
+        for i in 0..lon_lines {
+            let angle = std::f32::consts::TAU * (i as f32 / lon_lines as f32); // 0..TAU
+            let normal = Vec3::new(angle.cos(), 0.0, angle.sin());
+            self.draw_circle(center, radius, normal, color);
+        }
+    }
+
+    /// Draws a wireframe axis-aligned bounding box (AABB) from `min` to `max`.
+    ///
+    /// Also used for rendering a cube at a minimum position and a maximum position.
+    pub fn draw_aabb(&mut self, min: Vec3, max: Vec3, colour: [f32; 4]) {
+        let corners = [
+            Vec3::new(min.x, min.y, min.z),
+            Vec3::new(max.x, min.y, min.z),
+            Vec3::new(max.x, max.y, min.z),
+            Vec3::new(min.x, max.y, min.z),
+            Vec3::new(min.x, min.y, max.z),
+            Vec3::new(max.x, min.y, max.z),
+            Vec3::new(max.x, max.y, max.z),
+            Vec3::new(min.x, max.y, max.z),
+        ];
+
+        // bottom face
+        self.draw_line(corners[0], corners[1], colour);
+        self.draw_line(corners[1], corners[2], colour);
+        self.draw_line(corners[2], corners[3], colour);
+        self.draw_line(corners[3], corners[0], colour);
+        // top face
+        self.draw_line(corners[4], corners[5], colour);
+        self.draw_line(corners[5], corners[6], colour);
+        self.draw_line(corners[6], corners[7], colour);
+        self.draw_line(corners[7], corners[4], colour);
+        // verticals
+        self.draw_line(corners[0], corners[4], colour);
+        self.draw_line(corners[1], corners[5], colour);
+        self.draw_line(corners[2], corners[6], colour);
+        self.draw_line(corners[3], corners[7], colour);
+    }
+
+    /// Draws a wireframe oriented bounding box (OBB) at `center`.
+    ///
+    /// `half_extents` defines the box dimensions along each local axis.
+    /// `rotation` orients the box in world space.
+    pub fn draw_obb(&mut self, center: Vec3, half_extents: Vec3, rotation: Quat, colour: [f32; 4]) {
+        // rotate the 8 unit corners then scale by half_extents
+        let corners_local = [
+            Vec3::new(-1.0, -1.0, -1.0),
+            Vec3::new( 1.0, -1.0, -1.0),
+            Vec3::new( 1.0,  1.0, -1.0),
+            Vec3::new(-1.0,  1.0, -1.0),
+            Vec3::new(-1.0, -1.0,  1.0),
+            Vec3::new( 1.0, -1.0,  1.0),
+            Vec3::new( 1.0,  1.0,  1.0),
+            Vec3::new(-1.0,  1.0,  1.0),
+        ];
+
+        let corners: Vec<Vec3> = corners_local
+            .iter()
+            .map(|&c| center + rotation * (c * half_extents))
+            .collect();
+
+        // same edge layout as AABB
+        self.draw_line(corners[0], corners[1], colour);
+        self.draw_line(corners[1], corners[2], colour);
+        self.draw_line(corners[2], corners[3], colour);
+        self.draw_line(corners[3], corners[0], colour);
+        self.draw_line(corners[4], corners[5], colour);
+        self.draw_line(corners[5], corners[6], colour);
+        self.draw_line(corners[6], corners[7], colour);
+        self.draw_line(corners[7], corners[4], colour);
+        self.draw_line(corners[0], corners[4], colour);
+        self.draw_line(corners[1], corners[5], colour);
+        self.draw_line(corners[2], corners[6], colour);
+        self.draw_line(corners[3], corners[7], colour);
+    }
+
+    /// Draws a wireframe capsule between points `a` (bottom) and `b` (top) with the given `radius`.
+    ///
+    /// Rendered as two end circles, four connecting lines, and hemispherical arcs on each cap.
+    pub fn draw_capsule(&mut self, a: Vec3, b: Vec3, radius: f32, colour: [f32; 4]) {
+        let axis = (b - a).normalize();
+
+        // two end circles
+        self.draw_circle(a, radius, axis, colour);
+        self.draw_circle(b, radius, axis, colour);
+
+        // 4 connecting lines along the sides
+        let up = if axis.dot(Vec3::Y).abs() < 0.99 { Vec3::Y } else { Vec3::Z };
+        let tangent = axis.cross(up).normalize();
+        let bitangent = axis.cross(tangent).normalize();
+
+        for dir in [tangent, -tangent, bitangent, -bitangent] {
+            self.draw_line(a + dir * radius, b + dir * radius, colour);
+        }
+    }
+
+    /// Draws a wireframe cone from `apex` extending in `dir`.
+    ///
+    /// `angle` is the half-angle of the cone in radians.
+    ///
+    /// `length` controls how far the cone extends from the apex.
+    pub fn draw_cone(&mut self, apex: Vec3, dir: Vec3, angle: f32, length: f32, colour: [f32; 4]) {
+        let base_center = apex + dir.normalize() * length;
+        let base_radius = length * angle.tan();
+
+        // base circle
+        self.draw_circle(base_center, base_radius, dir, colour);
+
+        // 4 lines from apex to base rim
+        let up = if dir.normalize().dot(Vec3::Y).abs() < 0.99 { Vec3::Y } else { Vec3::Z };
+        let tangent = dir.cross(up).normalize();
+        let bitangent = dir.cross(tangent).normalize();
+
+        for side in [tangent, -tangent, bitangent, -bitangent] {
+            self.draw_line(apex, base_center + side * base_radius, colour);
+        }
+    }
+
+    /// Draws a wireframe frustum by unprojecting the 8 NDC corners using the inverse of `view_proj`.
+    ///
+    /// Pass in `camera.view_proj()` to visualise the camera's view frustum.
+    pub fn draw_frustum(&mut self, view_proj: Mat4, colour: [f32; 4]) {
+        // NDC corners, unproject back to world space
+        let ndc_corners = [
+            Vec3::new(-1.0, -1.0, 0.0), // near
+            Vec3::new( 1.0, -1.0, 0.0),
+            Vec3::new( 1.0,  1.0, 0.0),
+            Vec3::new(-1.0,  1.0, 0.0),
+            Vec3::new(-1.0, -1.0, 1.0), // far
+            Vec3::new( 1.0, -1.0, 1.0),
+            Vec3::new( 1.0,  1.0, 1.0),
+            Vec3::new(-1.0,  1.0, 1.0),
+        ];
+
+        let inv = view_proj.inverse();
+
+        let corners: Vec<Vec3> = ndc_corners.iter().map(|&ndc| {
+            let clip = Vec4::new(ndc.x, ndc.y, ndc.z, 1.0);
+            let world = inv * clip;
+            world.truncate() / world.w
+        }).collect();
+
+        // near face
+        self.draw_line(corners[0], corners[1], colour);
+        self.draw_line(corners[1], corners[2], colour);
+        self.draw_line(corners[2], corners[3], colour);
+        self.draw_line(corners[3], corners[0], colour);
+        // far face
+        self.draw_line(corners[4], corners[5], colour);
+        self.draw_line(corners[5], corners[6], colour);
+        self.draw_line(corners[6], corners[7], colour);
+        self.draw_line(corners[7], corners[4], colour);
+        // connecting edges
+        self.draw_line(corners[0], corners[4], colour);
+        self.draw_line(corners[1], corners[5], colour);
+        self.draw_line(corners[2], corners[6], colour);
+        self.draw_line(corners[3], corners[7], colour);
+    }
+
+    // curves and paths
+
+    /// Draws a polyline through a slice of points, connecting each adjacent pair with a line.
+    pub fn draw_polyline(&mut self, points: &[Vec3], colour: [f32; 4]) {
+        for window in points.windows(2) {
+            self.draw_line(window[0], window[1], colour);
+        }
+    }
+
+    /// Draws a Catmull-Rom spline through `points`, tessellated into `segments_per_span` line segments per span.
+    /// Requires at least 4 control points. The curve passes through all points except the first and last,
+    /// which act as phantom tangent guides.
+    ///
+    /// Duplicate the first and last point if you want the curve to reach the endpoints.
+    pub fn draw_spline(&mut self, points: &[Vec3], segments_per_span: u32, colour: [f32; 4]) {
+        if points.len() < 4 {
+            return;
+        }
+
+        for i in 0..points.len().saturating_sub(3) {
+            let (p0, p1, p2, p3) = (points[i], points[i+1], points[i+2], points[i+3]);
+            let mut prev = p1; // catmull-rom passes through p1..p_{n-2}
+            for j in 1..=segments_per_span {
+                let t = j as f32 / segments_per_span as f32;
+                let next = catmull_rom(p0, p1, p2, p3, t);
+                self.draw_line(prev, next, colour);
+                prev = next;
+            }
+        }
+    }
+
+    /// Draws a point cross at each vertex position in `vertices`.
+    pub fn draw_vertices(&mut self, vertices: &[Vec3], colour: [f32; 4]) {
+        for &v in vertices {
+            self.draw_point(v, 0.05, colour);
+        }
+    }
+}
+
+fn catmull_rom(p0: Vec3, p1: Vec3, p2: Vec3, p3: Vec3, t: f32) -> Vec3 {
+    let t2 = t * t;
+    let t3 = t2 * t;
+    0.5 * (
+        (2.0 * p1)
+            + (-p0 + p2) * t
+            + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2
+            + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3
+    )
+}
+
 pub struct DebugDrawPipeline {
+    uniform: UniformBuffer<Mat4>,
+    bind_group: wgpu::BindGroup,
     pipeline: RenderPipeline,
 }
 
 impl DebugDrawPipeline {
     pub fn new(graphics: Arc<SharedGraphicsContext>) -> Self {
+        let shader = Shader::new(graphics.clone(), include_str!("shaders/basic.wgsl"), Some("basic shader module"));
+
+        let bind_group_layout = graphics.device.create_bind_group_layout(&BindGroupLayoutDescriptor {
+            label: Some("basic camera bind group layout"),
+            entries: &[
+                BindGroupLayoutEntry {
+                    binding: 0,
+                    visibility: ShaderStages::VERTEX,
+                    ty: BindingType::Buffer {
+                        ty: BufferBindingType::Uniform,
+                        has_dynamic_offset: false,
+                        min_binding_size: None,
+                    },
+                    count: None,
+                }
+            ],
+        });
+
+        let camera_uniform: UniformBuffer<Mat4> = UniformBuffer::new(&graphics.device, "basic camera uniform");
+
+        let bind_group = graphics.device.create_bind_group(&BindGroupDescriptor {
+            label: Some("basic camera bind group"),
+            layout: &bind_group_layout,
+            entries: &[
+                BindGroupEntry {
+                    binding: 0,
+                    resource: BindingResource::Buffer(camera_uniform.buffer().as_entire_buffer_binding()),
+                }
+            ],
+        });
+
         let pipeline_layout = graphics.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
             label: Some("debug draw pipeline layout"),
-            bind_group_layouts: &[],
+            bind_group_layouts: &[
+                &bind_group_layout
+            ],
             push_constant_ranges: &[],
         });
 
+        let hdr_format = graphics.hdr.read().format();
+        let sample_count: u32 = (*graphics.antialiasing.read()).into();
+
         let pipeline = graphics.device.create_render_pipeline(&RenderPipelineDescriptor {
             label: Some("debug draw render pipeline"),
             layout: Some(&pipeline_layout),
             vertex: VertexState {
-                module: &(),
-                entry_point: None,
+                module: &shader.module,
+                entry_point: Some("vs_main"),
                 compilation_options: Default::default(),
-                buffers: &[],
+                buffers: &[DebugVertex::LAYOUT],
+            },
+            fragment: Some(wgpu::FragmentState {
+                module: &shader.module,
+                entry_point: Some("fs_main"),
+                targets: &[Some(wgpu::ColorTargetState {
+                    format: hdr_format,
+                    blend: Some(wgpu::BlendState::REPLACE),
+                    write_mask: wgpu::ColorWrites::ALL,
+                })],
+                compilation_options: wgpu::PipelineCompilationOptions::default(),
+            }),
+            primitive: PrimitiveState {
+                topology: PrimitiveTopology::LineList,
+                strip_index_format: None,
+                front_face: Default::default(),
+                cull_mode: None,
+                unclipped_depth: false,
+                polygon_mode: Default::default(),
+                conservative: false,
+            },
+            depth_stencil: Some(DepthStencilState {
+                format: TextureFormat::Depth32Float,
+                depth_write_enabled: false,  // don't write to depth, just read
+                depth_compare: CompareFunction::Less,
+                stencil: Default::default(),
+                bias: Default::default(),
+            }),
+            multisample: MultisampleState {
+                count: sample_count,
+                mask: !0,
+                alpha_to_coverage_enabled: false,
             },
-            primitive: Default::default(),
-            depth_stencil: None,
-            multisample: Default::default(),
-            fragment: None,
             multiview: None,
             cache: None,
         });
+
+        Self {
+            pipeline,
+            uniform: camera_uniform,
+            bind_group,
+        }
     }
 
-    pub fn draw(&self, graphics: Arc<SharedGraphicsContext>) {
+    pub fn draw(
+        &self,
+        graphics: Arc<SharedGraphicsContext>,
+        encoder: &mut CommandEncoder,
+        view_proj: Mat4,
+        vertex_buffer: &ResizableBuffer<DebugVertex>,
+        vertex_count: u32,
+    ) {
+        // update camera uniform
+        self.uniform.write(&graphics.queue, &view_proj);
+
+        if vertex_count == 0 {
+            return;
+        }
+
+        let hdr = graphics.hdr.read();
 
+        let mut pass = encoder.begin_render_pass(&RenderPassDescriptor {
+            label: Some("debug draw pass"),
+            color_attachments: &[Some(RenderPassColorAttachment {
+                view: hdr.view(),
+                depth_slice: None,
+                resolve_target: hdr.resolve_target(),
+                ops: Operations {
+                    load: LoadOp::Load,   // draw on top of existing frame
+                    store: StoreOp::Store,
+                },
+            })],
+            depth_stencil_attachment: Some(RenderPassDepthStencilAttachment {
+                view: &graphics.depth_texture.view,
+                depth_ops: Some(Operations {
+                    load: LoadOp::Load,   // read existing depth
+                    store: StoreOp::Store,
+                }),
+                stencil_ops: None,
+            }),
+            timestamp_writes: None,
+            occlusion_query_set: None,
+        });
+
+        pass.set_pipeline(&self.pipeline);
+        pass.set_bind_group(0, &self.bind_group, &[]);
+        pass.set_vertex_buffer(0, vertex_buffer.buffer().slice(..));
+        pass.draw(0..vertex_count, 0..1);
     }
 }
 
 #[repr(C)]
 #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
 pub struct DebugVertex {
-    pub position: [f32; 3],
-    pub color: [f32; 4],
+    pub position: [f32; 4], // ignore w value, used to ensure 16 bit pads
+    pub colour: [f32; 4],
+}
+
+impl DebugVertex {
+    pub const LAYOUT: VertexBufferLayout<'static> = VertexBufferLayout {
+        array_stride: size_of::<Self>() as wgpu::BufferAddress,
+        step_mode: wgpu::VertexStepMode::Vertex,
+        attributes: &wgpu::vertex_attr_array![
+            0 => Float32x4,
+            1 => Float32x4,
+        ],
+    };
 }
\ No newline at end of file
diff --git a/crates/dropbear-engine/src/graphics.rs b/crates/dropbear-engine/src/graphics.rs
index d081ead..c5072e8 100644
--- a/crates/dropbear-engine/src/graphics.rs
+++ b/crates/dropbear-engine/src/graphics.rs
@@ -1,3 +1,4 @@
+use crate::debug::DebugDraw;
 use crate::model::Vertex;
 use crate::{BindGroupLayouts, texture};
 use crate::{State, egui_renderer::EguiRenderer};
@@ -33,6 +34,7 @@ pub struct SharedGraphicsContext {
     pub hdr: Arc<RwLock<HdrPipeline>>,
     pub antialiasing: Arc<RwLock<AntiAliasingMode>>,
     pub layouts: Arc<BindGroupLayouts>,
+    pub debug_draw: Arc<Mutex<Option<DebugDraw>>>,
 }
 
 impl SharedGraphicsContext {
@@ -60,6 +62,7 @@ impl SharedGraphicsContext {
             surface_config: state.config.clone(),
             antialiasing: state.antialiasing.clone(),
             layouts: state.layouts.clone(),
+            debug_draw: state.debug_draw.clone(),
         }
     }
 }
diff --git a/crates/dropbear-engine/src/lib.rs b/crates/dropbear-engine/src/lib.rs
index 650d786..c4bb3ae 100644
--- a/crates/dropbear-engine/src/lib.rs
+++ b/crates/dropbear-engine/src/lib.rs
@@ -24,7 +24,7 @@ pub mod texture;
 pub mod utils;
 pub mod multisampling;
 pub mod billboarding;
-mod debug;
+pub mod debug;
 
 features! {
     pub mod feature_list {
@@ -71,6 +71,7 @@ use crate::egui_renderer::EguiRenderer;
 use crate::graphics::{CommandEncoder, SharedGraphicsContext};
 use crate::mipmap::MipMapper;
 use crate::texture::{Texture, TextureBuilder};
+use crate::debug::DebugDraw;
 
 use crate::pipelines::hdr::HdrPipeline;
 use crate::scene::Scene;
@@ -388,6 +389,7 @@ pub struct State {
     physics_accumulator: Duration,
 
     pub scene_manager: scene::Manager,
+    pub debug_draw: Arc<Mutex<Option<DebugDraw>>>,
     // pub yakui_renderer: Arc<Mutex<yakui_wgpu::YakuiWgpu>>,
     // pub yakui_texture: yakui::TextureId,
 }
@@ -588,6 +590,7 @@ Hardware:
             antialiasing: Arc::new(RwLock::new(antialiasing)),
             hdr,
             layouts: Arc::new(layouts),
+            debug_draw: Arc::new(Mutex::new(None)),
         };
 
         Ok(result)
diff --git a/crates/dropbear-engine/src/pipelines/light_cube.rs b/crates/dropbear-engine/src/pipelines/light_cube.rs
index 0b44d75..495c21b 100644
--- a/crates/dropbear-engine/src/pipelines/light_cube.rs
+++ b/crates/dropbear-engine/src/pipelines/light_cube.rs
@@ -95,7 +95,7 @@ impl DropbearShaderPipeline for LightCubePipeline {
             });
 
         let storage_buffer =
-            StorageBuffer::new(&graphics.device, "light cube pipeline storage buffer");
+            StorageBuffer::new_read_only(&graphics.device, "light cube pipeline storage buffer");
 
         Self {
             shader,
diff --git a/crates/dropbear-engine/src/shaders/basic.wgsl b/crates/dropbear-engine/src/shaders/basic.wgsl
index d5ac96e..2c3db46 100644
--- a/crates/dropbear-engine/src/shaders/basic.wgsl
+++ b/crates/dropbear-engine/src/shaders/basic.wgsl
@@ -1,29 +1,26 @@
 // basic.wgsl - shader used for drawing debug stuff like lines and shii
 
-struct CameraUniform {
-    view_proj: mat4x4<f32>,
-}
-@group(0) @binding(0) var<uniform> camera: CameraUniform;
+@group(0) @binding(0) var<uniform> camera: mat4x4<f32>;
 
-struct VertexIn {
-    @location(0) position: vec3<f32>,
-    @location(1) color: vec4<f32>,
+struct VertexInput {
+    @location(0) position: vec4<f32>, // ignore w value
+    @location(1) colour: vec4<f32>,
 }
 
-struct VertexOut {
+struct VertexOutput {
     @builtin(position) clip_position: vec4<f32>,
-    @location(0) color: vec4<f32>,
+    @location(0) colour: vec4<f32>,
 }
 
 @vertex
-fn vs_main(in: VertexIn) -> VertexOut {
-    var out: VertexOut;
-    out.clip_position = camera.view_proj * vec4<f32>(in.position, 1.0);
-    out.color = in.color;
+fn vs_main(in: VertexInput) -> VertexOutput {
+    var out: VertexOutput;
+    out.clip_position = camera * vec4<f32>(in.position.xyz, 1.0);
+    out.colour = in.colour;
     return out;
 }
 
 @fragment
-fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
-    return in.color;
+fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
+    return in.colour;
 }
\ No newline at end of file
diff --git a/crates/eucalyptus-core/src/debug.rs b/crates/eucalyptus-core/src/debug.rs
new file mode 100644
index 0000000..45bb0f9
--- /dev/null
+++ b/crates/eucalyptus-core/src/debug.rs
@@ -0,0 +1,194 @@
+use crate::ptr::GraphicsContextPtr;
+use crate::scripting::result::DropbearNativeResult;
+use crate::types::{NColour, NQuaternion, NVector3};
+use dropbear_engine::graphics::SharedGraphicsContext;
+use glam::{Quat, Vec3};
+
+macro_rules! with_debug {
+    ($graphics:expr, |$dd:ident| $body:expr) => {
+        if let Some($dd) = $graphics.debug_draw.lock().as_mut() {
+            $body
+        }
+    };
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawLine"),
+    c
+)]
+fn debug_draw_line(
+    #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
+    start: &NVector3,
+    end: &NVector3,
+    colour: &NColour,
+) -> DropbearNativeResult<()> {
+    let a = Vec3::new(start.x as f32, start.y as f32, start.z as f32);
+    let b = Vec3::new(end.x as f32, end.y as f32, end.z as f32);
+    with_debug!(graphics, |dd| dd.draw_line(a, b, colour.to_f32_array()));
+    Ok(())
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawRay"),
+    c
+)]
+fn debug_draw_ray(
+    #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
+    origin: &NVector3,
+    dir: &NVector3,
+    colour: &NColour,
+) -> DropbearNativeResult<()> {
+    let o = Vec3::new(origin.x as f32, origin.y as f32, origin.z as f32);
+    let d = Vec3::new(dir.x as f32, dir.y as f32, dir.z as f32);
+    with_debug!(graphics, |dd| dd.draw_ray(o, d, colour.to_f32_array()));
+    Ok(())
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawArrow"),
+    c
+)]
+fn debug_draw_arrow(
+    #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
+    start: &NVector3,
+    end: &NVector3,
+    colour: &NColour,
+) -> DropbearNativeResult<()> {
+    let a = Vec3::new(start.x as f32, start.y as f32, start.z as f32);
+    let b = Vec3::new(end.x as f32, end.y as f32, end.z as f32);
+    with_debug!(graphics, |dd| dd.draw_arrow(a, b, colour.to_f32_array()));
+    Ok(())
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawPoint"),
+    c
+)]
+fn debug_draw_point(
+    #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
+    pos: &NVector3,
+    size: f32,
+    colour: &NColour,
+) -> DropbearNativeResult<()> {
+    let p = Vec3::new(pos.x as f32, pos.y as f32, pos.z as f32);
+    with_debug!(graphics, |dd| dd.draw_point(p, size, colour.to_f32_array()));
+    Ok(())
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawCircle"),
+    c
+)]
+fn debug_draw_circle(
+    #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
+    center: &NVector3,
+    radius: f32,
+    normal: &NVector3,
+    colour: &NColour,
+) -> DropbearNativeResult<()> {
+    let c = Vec3::new(center.x as f32, center.y as f32, center.z as f32);
+    let n = Vec3::new(normal.x as f32, normal.y as f32, normal.z as f32);
+    with_debug!(graphics, |dd| dd.draw_circle(c, radius, n, colour.to_f32_array()));
+    Ok(())
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawSphere"),
+    c
+)]
+fn debug_draw_sphere(
+    #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
+    center: &NVector3,
+    radius: f32,
+    colour: &NColour,
+) -> DropbearNativeResult<()> {
+    let c = Vec3::new(center.x as f32, center.y as f32, center.z as f32);
+    with_debug!(graphics, |dd| dd.draw_sphere(c, radius, colour.to_f32_array()));
+    Ok(())
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawGlobe"),
+    c
+)]
+fn debug_draw_globe(
+    #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
+    center: &NVector3,
+    radius: f32,
+    lat_lines: u32,
+    lon_lines: u32,
+    colour: &NColour,
+) -> DropbearNativeResult<()> {
+    let c = Vec3::new(center.x as f32, center.y as f32, center.z as f32);
+    with_debug!(graphics, |dd| dd.draw_globe(c, radius, lat_lines, lon_lines, colour.to_f32_array()));
+    Ok(())
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawAabb"),
+    c
+)]
+fn debug_draw_aabb(
+    #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
+    min: &NVector3,
+    max: &NVector3,
+    colour: &NColour,
+) -> DropbearNativeResult<()> {
+    let mn = Vec3::new(min.x as f32, min.y as f32, min.z as f32);
+    let mx = Vec3::new(max.x as f32, max.y as f32, max.z as f32);
+    with_debug!(graphics, |dd| dd.draw_aabb(mn, mx, colour.to_f32_array()));
+    Ok(())
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawObb"),
+    c
+)]
+fn debug_draw_obb(
+    #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
+    center: &NVector3,
+    half_extents: &NVector3,
+    rotation: &NQuaternion,
+    colour: &NColour,
+) -> DropbearNativeResult<()> {
+    let c = Vec3::new(center.x as f32, center.y as f32, center.z as f32);
+    let he = Vec3::new(half_extents.x as f32, half_extents.y as f32, half_extents.z as f32);
+    let r = Quat::from_xyzw(rotation.x as f32, rotation.y as f32, rotation.z as f32, rotation.w as f32);
+    with_debug!(graphics, |dd| dd.draw_obb(c, he, r, colour.to_f32_array()));
+    Ok(())
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawCapsule"),
+    c
+)]
+fn debug_draw_capsule(
+    #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
+    a: &NVector3,
+    b: &NVector3,
+    radius: f32,
+    colour: &NColour,
+) -> DropbearNativeResult<()> {
+    let va = Vec3::new(a.x as f32, a.y as f32, a.z as f32);
+    let vb = Vec3::new(b.x as f32, b.y as f32, b.z as f32);
+    with_debug!(graphics, |dd| dd.draw_capsule(va, vb, radius, colour.to_f32_array()));
+    Ok(())
+}
+
+#[dropbear_macro::export(
+    kotlin(class = "com.dropbear.rendering.DebugDrawNative", func = "drawCone"),
+    c
+)]
+fn debug_draw_cone(
+    #[dropbear_macro::define(GraphicsContextPtr)] graphics: &SharedGraphicsContext,
+    apex: &NVector3,
+    dir: &NVector3,
+    angle: f32,
+    length: f32,
+    colour: &NColour,
+) -> DropbearNativeResult<()> {
+    let a = Vec3::new(apex.x as f32, apex.y as f32, apex.z as f32);
+    let d = Vec3::new(dir.x as f32, dir.y as f32, dir.z as f32);
+    with_debug!(graphics, |dd| dd.draw_cone(a, d, angle, length, colour.to_f32_array()));
+    Ok(())
+}
diff --git a/crates/eucalyptus-core/src/lib.rs b/crates/eucalyptus-core/src/lib.rs
index 8cf1095..b4203ac 100644
--- a/crates/eucalyptus-core/src/lib.rs
+++ b/crates/eucalyptus-core/src/lib.rs
@@ -5,6 +5,7 @@ pub mod camera;
 pub mod command;
 pub mod component;
 pub mod config;
+pub mod debug;
 pub mod engine;
 pub mod entity;
 pub mod entity_status;
diff --git a/crates/eucalyptus-core/src/lighting.rs b/crates/eucalyptus-core/src/lighting.rs
index ddb35f0..65ea9c3 100644
--- a/crates/eucalyptus-core/src/lighting.rs
+++ b/crates/eucalyptus-core/src/lighting.rs
@@ -6,7 +6,7 @@ use crate::scripting::jni::utils::{FromJObject, ToJObject};
 use crate::scripting::native::DropbearNativeError;
 use crate::scripting::result::DropbearNativeResult;
 use crate::states::SerializedLight;
-use crate::types::NVector3;
+use crate::types::{NColour, NVector3};
 use dropbear_engine::attenuation::ATTENUATION_PRESETS;
 use dropbear_engine::entity::{EntityTransform, Transform, inspect_rotation_dquat};
 use dropbear_engine::graphics::SharedGraphicsContext;
@@ -334,15 +334,6 @@ impl InspectableComponent for Light {
     }
 }
 
-#[repr(C)]
-#[derive(Clone, Copy, Debug)]
-struct NColour {
-    r: u8,
-    g: u8,
-    b: u8,
-    a: u8,
-}
-
 impl NColour {
     fn to_linear_rgb(self) -> DVec3 {
         DVec3::new(
diff --git a/crates/eucalyptus-core/src/types.rs b/crates/eucalyptus-core/src/types.rs
index 10b4bf1..b1d9dbb 100644
--- a/crates/eucalyptus-core/src/types.rs
+++ b/crates/eucalyptus-core/src/types.rs
@@ -15,6 +15,26 @@ use rapier3d::prelude::ColliderHandle;
 
 #[repr(C)]
 #[derive(Clone, Copy, Debug)]
+pub struct NColour {
+    pub r: u8,
+    pub g: u8,
+    pub b: u8,
+    pub a: u8,
+}
+
+impl NColour {
+    pub fn to_f32_array(self) -> [f32; 4] {
+        [
+            self.r as f32 / 255.0,
+            self.g as f32 / 255.0,
+            self.b as f32 / 255.0,
+            self.a as f32 / 255.0,
+        ]
+    }
+}
+
+#[repr(C)]
+#[derive(Clone, Copy, Debug)]
 pub struct NVector2 {
     pub x: f64,
     pub y: f64,
diff --git a/crates/redback-runtime/src/lib.rs b/crates/redback-runtime/src/lib.rs
index f3e4a1c..98bca60 100644
--- a/crates/redback-runtime/src/lib.rs
+++ b/crates/redback-runtime/src/lib.rs
@@ -398,6 +398,7 @@ impl PlayMode {
         ));
 
         self.billboard_pipeline = Some(BillboardPipeline::new(graphics.clone()));
+        *graphics.debug_draw.lock() = Some(dropbear_engine::debug::DebugDraw::new(graphics.clone()));
 
         let sky_texture_result = HdrLoader::from_equirectangular_bytes(
             &graphics.device,
diff --git a/crates/redback-runtime/src/scene.rs b/crates/redback-runtime/src/scene.rs
index fa9ff67..df89924 100644
--- a/crates/redback-runtime/src/scene.rs
+++ b/crates/redback-runtime/src/scene.rs
@@ -1454,6 +1454,12 @@ impl Scene for PlayMode {
             }
         }
 
+        // debug draw flush
+        if let Some(debug_draw) = graphics.debug_draw.lock().as_mut() {
+            let view_proj = Mat4::from_cols_array_2d(&camera.uniform.view_proj);
+            debug_draw.flush(graphics.clone(), &mut encoder, view_proj);
+        }
+
         hdr.process(&mut encoder, &graphics.viewport_texture.view);
 
         if let Err(e) = encoder.submit() {
diff --git a/include/dropbear.h b/include/dropbear.h
index 3ffb323..30df989 100644
--- a/include/dropbear.h
+++ b/include/dropbear.h
@@ -520,6 +520,17 @@ int32_t dropbear_collider_set_collider_restitution(PhysicsStatePtr physics, cons
 int32_t dropbear_collider_set_collider_rotation(PhysicsStatePtr physics, const NCollider* collider, const NVector3* rotation);
 int32_t dropbear_collider_set_collider_shape(PhysicsStatePtr physics, const NCollider* collider, const ColliderShape* shape);
 int32_t dropbear_collider_set_collider_translation(PhysicsStatePtr physics, const NCollider* collider, const NVector3* translation);
+int32_t dropbear_debug_debug_draw_aabb(GraphicsContextPtr graphics, const NVector3* min, const NVector3* max, const NColour* colour);
+int32_t dropbear_debug_debug_draw_arrow(GraphicsContextPtr graphics, const NVector3* start, const NVector3* end, const NColour* colour);
+int32_t dropbear_debug_debug_draw_capsule(GraphicsContextPtr graphics, const NVector3* a, const NVector3* b, float radius, const NColour* colour);
+int32_t dropbear_debug_debug_draw_circle(GraphicsContextPtr graphics, const NVector3* center, float radius, const NVector3* normal, const NColour* colour);
+int32_t dropbear_debug_debug_draw_cone(GraphicsContextPtr graphics, const NVector3* apex, const NVector3* dir, float angle, float length, const NColour* colour);
+int32_t dropbear_debug_debug_draw_globe(GraphicsContextPtr graphics, const NVector3* center, float radius, uint32_t lat_lines, uint32_t lon_lines, const NColour* colour);
+int32_t dropbear_debug_debug_draw_line(GraphicsContextPtr graphics, const NVector3* start, const NVector3* end, const NColour* colour);
+int32_t dropbear_debug_debug_draw_obb(GraphicsContextPtr graphics, const NVector3* center, const NVector3* half_extents, const NQuaternion* rotation, const NColour* colour);
+int32_t dropbear_debug_debug_draw_point(GraphicsContextPtr graphics, const NVector3* pos, float size, const NColour* colour);
+int32_t dropbear_debug_debug_draw_ray(GraphicsContextPtr graphics, const NVector3* origin, const NVector3* dir, const NColour* colour);
+int32_t dropbear_debug_debug_draw_sphere(GraphicsContextPtr graphics, const NVector3* center, float radius, const NColour* colour);
 int32_t dropbear_engine_get_asset(AssetRegistryPtr asset, const char* label, const AssetKind* kind, uint64_t* out0, bool* out0_present);
 int32_t dropbear_engine_get_entity(WorldPtr world, const char* label, uint64_t* out0);
 int32_t dropbear_engine_quit(CommandBufferPtr command_buffer);
diff --git a/scripting/commonMain/kotlin/com/dropbear/rendering/DebugDraw.kt b/scripting/commonMain/kotlin/com/dropbear/rendering/DebugDraw.kt
new file mode 100644
index 0000000..4854e9d
--- /dev/null
+++ b/scripting/commonMain/kotlin/com/dropbear/rendering/DebugDraw.kt
@@ -0,0 +1,86 @@
+package com.dropbear.rendering
+
+import com.dropbear.math.Quaterniond
+import com.dropbear.math.Vector3d
+import com.dropbear.utils.Colour
+
+/**
+ * Functions related to drawing to aid with debugging, such as wireframe lines and shapes.
+ *
+ * All draws are cleared at the end of each frame automatically.
+ */
+object DebugDraw {
+    /** Draws a line between [start] and [end] with the given [colour]. */
+    fun drawLine(start: Vector3d, end: Vector3d, colour: Colour = Colour.WHITE) =
+        drawLineNative(start, end, colour)
+
+    /** Draws a ray from [origin] in the direction [dir] with the given [colour]. */
+    fun drawRay(origin: Vector3d, dir: Vector3d, colour: Colour = Colour.WHITE) =
+        drawRayNative(origin, dir, colour)
+
+    /** Draws a line from [start] to [end] with an arrowhead at [end]. */
+    fun drawArrow(start: Vector3d, end: Vector3d, colour: Colour = Colour.WHITE) =
+        drawArrowNative(start, end, colour)
+
+    /** Draws a cross at [pos] with the given [size]. */
+    fun drawPoint(pos: Vector3d, size: Double = 0.1, colour: Colour = Colour.WHITE) =
+        drawPointNative(pos, size.toFloat(), colour)
+
+    /**
+     * Draws a circle at [center] with the given [radius].
+     * [normal] defines the axis the circle faces (e.g. [Vector3d.up] for a flat ground circle).
+     */
+    fun drawCircle(center: Vector3d, radius: Double, normal: Vector3d = Vector3d.up(), colour: Colour = Colour.WHITE) =
+        drawCircleNative(center, radius.toFloat(), normal, colour)
+
+    /**
+     * Draws three axis-aligned circles at [center] to approximate a sphere outline.
+     * For a proper globe, see [drawGlobe].
+     */
+    fun drawSphere(center: Vector3d, radius: Double, colour: Colour = Colour.WHITE) =
+        drawSphereNative(center, radius.toFloat(), colour)
+
+    /**
+     * Draws a wireframe globe at [center] using latitude and longitude rings.
+     * [latLines] controls horizontal rings; [lonLines] controls vertical rings.
+     */
+    fun drawGlobe(center: Vector3d, radius: Double, latLines: Int = 8, lonLines: Int = 8, colour: Colour = Colour.WHITE) =
+        drawGlobeNative(center, radius.toFloat(), latLines, lonLines, colour)
+
+    /** Draws a wireframe axis-aligned bounding box (AABB) from [min] to [max]. */
+    fun drawAabb(min: Vector3d, max: Vector3d, colour: Colour = Colour.WHITE) =
+        drawAabbNative(min, max, colour)
+
+    /**
+     * Draws a wireframe oriented bounding box (OBB) at [center].
+     * [halfExtents] defines the local half-sizes along each axis.
+     * [rotation] orients the box in world space.
+     */
+    fun drawObb(center: Vector3d, halfExtents: Vector3d, rotation: Quaterniond = Quaterniond.identity(), colour: Colour = Colour.WHITE) =
+        drawObbNative(center, halfExtents, rotation, colour)
+
+    /**
+     * Draws a wireframe capsule between [a] (bottom) and [b] (top) with the given [radius].
+     */
+    fun drawCapsule(a: Vector3d, b: Vector3d, radius: Double, colour: Colour = Colour.WHITE) =
+        drawCapsuleNative(a, b, radius.toFloat(), colour)
+
+    /**
+     * Draws a wireframe cone from [apex] extending in [dir].
+     * [angle] is the half-angle in radians. [length] controls how far the cone extends.
+     */
+    fun drawCone(apex: Vector3d, dir: Vector3d, angle: Double, length: Double, colour: Colour = Colour.WHITE) =
+        drawConeNative(apex, dir, angle.toFloat(), length.toFloat(), colour)
+}
+
+internal expect fun DebugDraw.drawLineNative(start: Vector3d, end: Vector3d, colour: Colour)
+internal expect fun DebugDraw.drawRayNative(origin: Vector3d, dir: Vector3d, colour: Colour)
+internal expect fun DebugDraw.drawArrowNative(start: Vector3d, end: Vector3d, colour: Colour)
+internal expect fun DebugDraw.drawPointNative(pos: Vector3d, size: Float, colour: Colour)
+internal expect fun DebugDraw.drawCircleNative(center: Vector3d, radius: Float, normal: Vector3d, colour: Colour)
+internal expect fun DebugDraw.drawSphereNative(center: Vector3d, radius: Float, colour: Colour)
+internal expect fun DebugDraw.drawGlobeNative(center: Vector3d, radius: Float, latLines: Int, lonLines: Int, colour: Colour)
+internal expect fun DebugDraw.drawAabbNative(min: Vector3d, max: Vector3d, colour: Colour)
+internal expect fun DebugDraw.drawObbNative(center: Vector3d, halfExtents: Vector3d, rotation: Quaterniond, colour: Colour)
+internal expect fun DebugDraw.drawCapsuleNative(a: Vector3d, b: Vector3d, radius: Float, colour: Colour)
+internal expect fun DebugDraw.drawConeNative(apex: Vector3d, dir: Vector3d, angle: Float, length: Float, colour: Colour)
diff --git a/scripting/jvmMain/java/com/dropbear/rendering/DebugDrawNative.java b/scripting/jvmMain/java/com/dropbear/rendering/DebugDrawNative.java
new file mode 100644
index 0000000..f50afad
--- /dev/null
+++ b/scripting/jvmMain/java/com/dropbear/rendering/DebugDrawNative.java
@@ -0,0 +1,24 @@
+package com.dropbear.rendering;
+
+import com.dropbear.EucalyptusCoreLoader;
+import com.dropbear.math.Quaterniond;
+import com.dropbear.math.Vector3d;
+import com.dropbear.utils.Colour;
+
+public class DebugDrawNative {
+    static {
+        new EucalyptusCoreLoader().ensureLoaded();
+    }
+
+    public static native void drawLine(long graphicsContextPtr, Vector3d start, Vector3d end, Colour colour);
+    public static native void drawRay(long graphicsContextPtr, Vector3d origin, Vector3d dir, Colour colour);
+    public static native void drawArrow(long graphicsContextPtr, Vector3d start, Vector3d end, Colour colour);
+    public static native void drawPoint(long graphicsContextPtr, Vector3d pos, float size, Colour colour);
+    public static native void drawCircle(long graphicsContextPtr, Vector3d center, float radius, Vector3d normal, Colour colour);
+    public static native void drawSphere(long graphicsContextPtr, Vector3d center, float radius, Colour colour);
+    public static native void drawGlobe(long graphicsContextPtr, Vector3d center, float radius, int latLines, int lonLines, Colour colour);
+    public static native void drawAabb(long graphicsContextPtr, Vector3d min, Vector3d max, Colour colour);
+    public static native void drawObb(long graphicsContextPtr, Vector3d center, Vector3d halfExtents, Quaterniond rotation, Colour colour);
+    public static native void drawCapsule(long graphicsContextPtr, Vector3d a, Vector3d b, float radius, Colour colour);
+    public static native void drawCone(long graphicsContextPtr, Vector3d apex, Vector3d dir, float angle, float length, Colour colour);
+}
diff --git a/scripting/jvmMain/kotlin/com/dropbear/rendering/DebugDraw.jvm.kt b/scripting/jvmMain/kotlin/com/dropbear/rendering/DebugDraw.jvm.kt
new file mode 100644
index 0000000..b6a5527
--- /dev/null
+++ b/scripting/jvmMain/kotlin/com/dropbear/rendering/DebugDraw.jvm.kt
@@ -0,0 +1,41 @@
+package com.dropbear.rendering
+
+import com.dropbear.DropbearEngine
+import com.dropbear.math.Quaterniond
+import com.dropbear.math.Vector3d
+import com.dropbear.utils.Colour
+
+private val g get() = DropbearEngine.native.graphicsContextHandle
+
+internal actual fun DebugDraw.drawLineNative(start: Vector3d, end: Vector3d, colour: Colour) =
+    DebugDrawNative.drawLine(g, start, end, colour)
+
+internal actual fun DebugDraw.drawRayNative(origin: Vector3d, dir: Vector3d, colour: Colour) =
+    DebugDrawNative.drawRay(g, origin, dir, colour)
+
+internal actual fun DebugDraw.drawArrowNative(start: Vector3d, end: Vector3d, colour: Colour) =
+    DebugDrawNative.drawArrow(g, start, end, colour)
+
+internal actual fun DebugDraw.drawPointNative(pos: Vector3d, size: Float, colour: Colour) =
+    DebugDrawNative.drawPoint(g, pos, size, colour)
+
+internal actual fun DebugDraw.drawCircleNative(center: Vector3d, radius: Float, normal: Vector3d, colour: Colour) =
+    DebugDrawNative.drawCircle(g, center, radius, normal, colour)
+
+internal actual fun DebugDraw.drawSphereNative(center: Vector3d, radius: Float, colour: Colour) =
+    DebugDrawNative.drawSphere(g, center, radius, colour)
+
+internal actual fun DebugDraw.drawGlobeNative(center: Vector3d, radius: Float, latLines: Int, lonLines: Int, colour: Colour) =
+    DebugDrawNative.drawGlobe(g, center, radius, latLines, lonLines, colour)
+
+internal actual fun DebugDraw.drawAabbNative(min: Vector3d, max: Vector3d, colour: Colour) =
+    DebugDrawNative.drawAabb(g, min, max, colour)
+
+internal actual fun DebugDraw.drawObbNative(center: Vector3d, halfExtents: Vector3d, rotation: Quaterniond, colour: Colour) =
+    DebugDrawNative.drawObb(g, center, halfExtents, rotation, colour)
+
+internal actual fun DebugDraw.drawCapsuleNative(a: Vector3d, b: Vector3d, radius: Float, colour: Colour) =
+    DebugDrawNative.drawCapsule(g, a, b, radius, colour)
+
+internal actual fun DebugDraw.drawConeNative(apex: Vector3d, dir: Vector3d, angle: Float, length: Float, colour: Colour) =
+    DebugDrawNative.drawCone(g, apex, dir, angle, length, colour)
diff --git a/scripting/nativeMain/kotlin/com/dropbear/rendering/DebugDraw.native.kt b/scripting/nativeMain/kotlin/com/dropbear/rendering/DebugDraw.native.kt
new file mode 100644
index 0000000..a30b4d5
--- /dev/null
+++ b/scripting/nativeMain/kotlin/com/dropbear/rendering/DebugDraw.native.kt
@@ -0,0 +1,144 @@
+@file:OptIn(ExperimentalForeignApi::class)
+
+package com.dropbear.rendering
+
+import com.dropbear.DropbearEngine
+import com.dropbear.ffi.generated.*
+import com.dropbear.math.Quaterniond
+import com.dropbear.math.Vector3d
+import com.dropbear.utils.Colour
+import kotlinx.cinterop.*
+
+internal actual fun DebugDraw.drawLineNative(start: Vector3d, end: Vector3d, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    dropbear_debug_draw_line(g, allocVec3(start).ptr, allocVec3(end).ptr, allocColour(colour).ptr)
+}
+
+internal actual fun DebugDraw.drawRayNative(origin: Vector3d, dir: Vector3d, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    dropbear_debug_draw_ray(g, allocVec3(origin).ptr, allocVec3(dir).ptr, allocColour(colour).ptr)
+}
+
+internal actual fun DebugDraw.drawArrowNative(start: Vector3d, end: Vector3d, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    dropbear_debug_draw_arrow(g, allocVec3(start).ptr, allocVec3(end).ptr, allocColour(colour).ptr)
+}
+
+internal actual fun DebugDraw.drawPointNative(pos: Vector3d, size: Float, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    dropbear_debug_draw_point(g, allocVec3(pos).ptr, size, allocColour(colour).ptr)
+}
+
+internal actual fun DebugDraw.drawCircleNative(center: Vector3d, radius: Float, normal: Vector3d, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    dropbear_debug_draw_circle(g, allocVec3(center).ptr, radius, allocVec3(normal).ptr, allocColour(colour).ptr)
+}
+
+internal actual fun DebugDraw.drawSphereNative(center: Vector3d, radius: Float, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    dropbear_debug_draw_sphere(g, allocVec3(center).ptr, radius, allocColour(colour).ptr)
+}
+
+internal actual fun DebugDraw.drawGlobeNative(center: Vector3d, radius: Float, latLines: Int, lonLines: Int, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    dropbear_debug_draw_globe(g, allocVec3(center).ptr, radius, latLines.toUInt(), lonLines.toUInt(), allocColour(colour).ptr)
+}
+
+internal actual fun DebugDraw.drawAabbNative(min: Vector3d, max: Vector3d, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    dropbear_debug_draw_aabb(g, allocVec3(min).ptr, allocVec3(max).ptr, allocColour(colour).ptr)
+}
+
+internal actual fun DebugDraw.drawObbNative(center: Vector3d, halfExtents: Vector3d, rotation: Quaterniond, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    dropbear_debug_draw_obb(g, allocVec3(center).ptr, allocVec3(halfExtents).ptr, allocQuat(rotation).ptr, allocColour(colour).ptr)
+}
+
+internal actual fun DebugDraw.drawCapsuleNative(a: Vector3d, b: Vector3d, radius: Float, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    dropbear_debug_draw_capsule(g, allocVec3(a).ptr, allocVec3(b).ptr, radius, allocColour(colour).ptr)
+}
+
+internal actual fun DebugDraw.drawConeNative(apex: Vector3d, dir: Vector3d, angle: Float, length: Float, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    dropbear_debug_draw_cone(g, allocVec3(apex).ptr, allocVec3(dir).ptr, angle, length, allocColour(colour).ptr)
+}
+
+
+internal actual fun DebugDraw.drawLineNative(start: Vector3d, end: Vector3d, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    val s = allocVec3(start); val e = allocVec3(end)
+    val c = nColour(colour).toCValue(this)
+    dropbear_debug_draw_line(g, s.ptr, e.ptr, c)
+}
+
+internal actual fun DebugDraw.drawRayNative(origin: Vector3d, dir: Vector3d, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    val o = allocVec3(origin); val d = allocVec3(dir)
+    val c = nColour(colour).toCValue(this)
+    dropbear_debug_draw_ray(g, o.ptr, d.ptr, c)
+}
+
+internal actual fun DebugDraw.drawArrowNative(start: Vector3d, end: Vector3d, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    val s = allocVec3(start); val e = allocVec3(end)
+    val c = nColour(colour).toCValue(this)
+    dropbear_debug_draw_arrow(g, s.ptr, e.ptr, c)
+}
+
+internal actual fun DebugDraw.drawPointNative(pos: Vector3d, size: Float, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    val p = allocVec3(pos)
+    val c = nColour(colour).toCValue(this)
+    dropbear_debug_draw_point(g, p.ptr, size, c)
+}
+
+internal actual fun DebugDraw.drawCircleNative(center: Vector3d, radius: Float, normal: Vector3d, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    val ct = allocVec3(center); val n = allocVec3(normal)
+    val c = nColour(colour).toCValue(this)
+    dropbear_debug_draw_circle(g, ct.ptr, radius, n.ptr, c)
+}
+
+internal actual fun DebugDraw.drawSphereNative(center: Vector3d, radius: Float, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    val ct = allocVec3(center)
+    val c = nColour(colour).toCValue(this)
+    dropbear_debug_draw_sphere(g, ct.ptr, radius, c)
+}
+
+internal actual fun DebugDraw.drawGlobeNative(center: Vector3d, radius: Float, latLines: Int, lonLines: Int, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    val ct = allocVec3(center)
+    val c = nColour(colour).toCValue(this)
+    dropbear_debug_draw_globe(g, ct.ptr, radius, latLines.toUInt(), lonLines.toUInt(), c)
+}
+
+internal actual fun DebugDraw.drawAabbNative(min: Vector3d, max: Vector3d, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    val mn = allocVec3(min); val mx = allocVec3(max)
+    val c = nColour(colour).toCValue(this)
+    dropbear_debug_draw_aabb(g, mn.ptr, mx.ptr, c)
+}
+
+internal actual fun DebugDraw.drawObbNative(center: Vector3d, halfExtents: Vector3d, rotation: Quaterniond, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    val ct = allocVec3(center); val he = allocVec3(halfExtents)
+    val rot = allocQuat(rotation)
+    val c = nColour(colour).toCValue(this)
+    dropbear_debug_draw_obb(g, ct.ptr, he.ptr, rot.ptr, c)
+}
+
+internal actual fun DebugDraw.drawCapsuleNative(a: Vector3d, b: Vector3d, radius: Float, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    val va = allocVec3(a); val vb = allocVec3(b)
+    val c = nColour(colour).toCValue(this)
+    dropbear_debug_draw_capsule(g, va.ptr, vb.ptr, radius, c)
+}
+
+internal actual fun DebugDraw.drawConeNative(apex: Vector3d, dir: Vector3d, angle: Float, length: Float, colour: Colour) = memScoped {
+    val g = DropbearEngine.native.graphicsContextHandle ?: return@memScoped
+    val a = allocVec3(apex); val d = allocVec3(dir)
+    val c = nColour(colour).toCValue(this)
+    dropbear_debug_draw_cone(g, a.ptr, d.ptr, angle, length, c)
+}