kitgit

tirbofish/dropbear · diff

0f3aade · Thribhu K

refactor: refactoring the render() functions to make it more organised, plan on optimising later.

Unverified

diff --git a/crates/dropbear-engine/src/animation.rs b/crates/dropbear-engine/src/animation.rs
index d78bbef..1e14b0a 100644
--- a/crates/dropbear-engine/src/animation.rs
+++ b/crates/dropbear-engine/src/animation.rs
@@ -6,6 +6,9 @@ use glam::Mat4;
 use std::collections::HashMap;
 use std::sync::Arc;
 
+pub const MAX_MORPH_WEIGHTS: usize = 4096;
+pub const MAX_SKINNING_MATRICES: usize = 256;
+
 #[repr(C)]
 #[derive(Copy, Clone, Default, Debug, bytemuck::Pod, bytemuck::Zeroable)]
 pub struct MorphTargetInfo {
@@ -573,5 +576,3 @@ impl AnimationComponent {
         }
     }
 }
-
-pub const MAX_MORPH_WEIGHTS: usize = 4096;
diff --git a/crates/dropbear-engine/src/buffer.rs b/crates/dropbear-engine/src/buffer.rs
index 331a647..edfbb51 100644
--- a/crates/dropbear-engine/src/buffer.rs
+++ b/crates/dropbear-engine/src/buffer.rs
@@ -1,6 +1,7 @@
 //! Vertices and different buffers used for wgpu
 
 use std::marker::PhantomData;
+use bytemuck::NoUninit;
 
 #[derive(Debug, Clone)]
 pub struct ResizableBuffer<T> {
@@ -11,7 +12,7 @@ pub struct ResizableBuffer<T> {
     _marker: PhantomData<T>,
 }
 
-impl<T: bytemuck::Pod> ResizableBuffer<T> {
+impl<T: NoUninit> ResizableBuffer<T> {
     pub fn new(
         device: &wgpu::Device,
         initial_capacity: usize,
@@ -85,7 +86,7 @@ pub struct UniformBuffer<T> {
     _marker: PhantomData<T>,
 }
 
-impl<T: bytemuck::Pod> UniformBuffer<T> {
+impl<T: NoUninit> 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 {
@@ -117,7 +118,6 @@ impl<T: bytemuck::Pod> UniformBuffer<T> {
     }
 }
 
-/// A wrapper to a [wgpu::Buffer] that stores
 #[derive(Debug, Clone)]
 pub struct StorageBuffer<T> {
     buffer: wgpu::Buffer,
@@ -125,7 +125,7 @@ pub struct StorageBuffer<T> {
     _marker: PhantomData<T>,
 }
 
-impl<T: bytemuck::Pod> StorageBuffer<T> {
+impl<T: NoUninit> StorageBuffer<T> {
     pub fn new_read_only(device: &wgpu::Device, label: &str) -> Self {
         Self::new(device, label, true)
     }
@@ -176,3 +176,27 @@ impl<T: bytemuck::Pod> StorageBuffer<T> {
         &self.label
     }
 }
+
+impl<T: NoUninit> StorageBuffer<T> {
+    pub fn new_slice(device: &wgpu::Device, label: &str, count: usize, 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>() * count) as wgpu::BufferAddress).max(16);
+        let buffer = device.create_buffer(&wgpu::BufferDescriptor {
+            label: Some(label),
+            size,
+            usage,
+            mapped_at_creation: false,
+        });
+
+        Self { buffer, label: label.to_string(), _marker: PhantomData }
+    }
+
+    pub fn write_slice(&self, queue: &wgpu::Queue, values: &[T]) {
+        queue.write_buffer(&self.buffer, 0, bytemuck::cast_slice(values));
+    }
+}
diff --git a/crates/dropbear-engine/src/pipelines/animation.rs b/crates/dropbear-engine/src/pipelines/animation.rs
new file mode 100644
index 0000000..80b1b87
--- /dev/null
+++ b/crates/dropbear-engine/src/pipelines/animation.rs
@@ -0,0 +1,73 @@
+use std::sync::Arc;
+use glam::{Mat4};
+use wgpu::util::DeviceExt;
+use dropbear_utils::Dirty;
+use crate::animation::{MorphTargetInfo, MAX_MORPH_WEIGHTS, MAX_SKINNING_MATRICES};
+use crate::buffer::{StorageBuffer, UniformBuffer};
+use crate::graphics::SharedGraphicsContext;
+
+pub struct AnimationDefaults {
+    pub skinning_buffer: StorageBuffer<Mat4>,
+    pub morph_deltas_buffer: StorageBuffer<f32>,
+    pub morph_weights_buffer: StorageBuffer<f32>,
+    pub morph_info_buffer: UniformBuffer<MorphTargetInfo>,
+    pub animation_bind_group: wgpu::BindGroup,
+}
+
+impl AnimationDefaults {
+    pub fn new(graphics: Arc<SharedGraphicsContext>) -> Self {
+        let identity = vec![Mat4::IDENTITY; MAX_SKINNING_MATRICES];
+        let skinning_buffer = StorageBuffer::new_slice(
+            &graphics.device, "editor default skinning buffer", MAX_SKINNING_MATRICES, true
+        );
+        skinning_buffer.write_slice(&graphics.queue, &identity);
+
+        // mapped to default value
+        let morph_deltas_buffer: StorageBuffer<f32> = StorageBuffer::new_read_only(
+            &graphics.device, "editor default morph deltas buffer"
+        );
+
+        let morph_weights_buffer: StorageBuffer<f32> = StorageBuffer::new_slice(
+            &graphics.device, "editor default skinning buffer", MAX_MORPH_WEIGHTS, true
+        );
+
+        let morph_info = MorphTargetInfo::default();
+        let morph_info_buffer = UniformBuffer::new(
+            &graphics.device, "editor default morph info buffer"
+        );
+        morph_info_buffer.write(&graphics.queue, &morph_info);
+
+        let animation_bind_group = graphics.device.create_bind_group(
+            &wgpu::BindGroupDescriptor {
+                label: Some("editor default animation bind group"),
+                layout: &graphics.layouts.animation_layout,
+                entries: &[
+                    wgpu::BindGroupEntry {
+                        binding: 0,
+                        resource: skinning_buffer.buffer().as_entire_binding(),
+                    },
+                    wgpu::BindGroupEntry {
+                        binding: 1,
+                        resource: morph_deltas_buffer.buffer().as_entire_binding(),
+                    },
+                    wgpu::BindGroupEntry {
+                        binding: 2,
+                        resource: morph_weights_buffer.buffer().as_entire_binding(),
+                    },
+                    wgpu::BindGroupEntry {
+                        binding: 3,
+                        resource: morph_info_buffer.buffer().as_entire_binding(),
+                    },
+                ],
+            },
+        );
+
+        Self {
+            skinning_buffer,
+            morph_deltas_buffer,
+            morph_weights_buffer,
+            morph_info_buffer,
+            animation_bind_group,
+        }
+    }
+}
\ No newline at end of file
diff --git a/crates/dropbear-engine/src/pipelines/mod.rs b/crates/dropbear-engine/src/pipelines/mod.rs
index 8752a28..a58249f 100644
--- a/crates/dropbear-engine/src/pipelines/mod.rs
+++ b/crates/dropbear-engine/src/pipelines/mod.rs
@@ -6,6 +6,7 @@ pub mod globals;
 pub mod hdr;
 pub mod light_cube;
 pub mod shader;
+pub mod animation;
 
 pub use globals::{Globals, GlobalsUniform};
 
diff --git a/crates/dropbear-engine/src/pipelines/shader.rs b/crates/dropbear-engine/src/pipelines/shader.rs
index eda5b21..cecfed1 100644
--- a/crates/dropbear-engine/src/pipelines/shader.rs
+++ b/crates/dropbear-engine/src/pipelines/shader.rs
@@ -153,76 +153,6 @@ impl MainRenderPipeline {
         self.per_frame.as_ref().unwrap() // safe as its guaranteed to always have some content
     }
 
-    pub fn per_material_bind_group(
-        &mut self,
-        graphics: Arc<SharedGraphicsContext>,
-        material_uniform_buffer: &wgpu::Buffer,
-        diffuse_texture: &Texture,
-        normal_texture: &Texture,
-        emissive_texture: &Texture,
-        metallic_texture: &Texture,
-        occlusion_texture: &Texture,
-    ) -> &wgpu::BindGroup {
-        if self.per_material.is_none() {
-            let bind_group = graphics
-                .device
-                .create_bind_group(&wgpu::BindGroupDescriptor {
-                    label: Some("per material bind group"),
-                    layout: &graphics.layouts.material_bind_layout,
-                    entries: &[
-                        wgpu::BindGroupEntry {
-                            binding: 0,
-                            resource: material_uniform_buffer.as_entire_binding(),
-                        },
-                        wgpu::BindGroupEntry {
-                            binding: 1,
-                            resource: wgpu::BindingResource::TextureView(&diffuse_texture.view),
-                        },
-                        wgpu::BindGroupEntry {
-                            binding: 2,
-                            resource: wgpu::BindingResource::Sampler(&diffuse_texture.sampler),
-                        },
-                        wgpu::BindGroupEntry {
-                            binding: 3,
-                            resource: wgpu::BindingResource::TextureView(&normal_texture.view),
-                        },
-                        wgpu::BindGroupEntry {
-                            binding: 4,
-                            resource: wgpu::BindingResource::Sampler(&normal_texture.sampler),
-                        },
-                        wgpu::BindGroupEntry {
-                            binding: 5,
-                            resource: wgpu::BindingResource::TextureView(&emissive_texture.view),
-                        },
-                        wgpu::BindGroupEntry {
-                            binding: 6,
-                            resource: wgpu::BindingResource::Sampler(&emissive_texture.sampler),
-                        },
-                        wgpu::BindGroupEntry {
-                            binding: 7,
-                            resource: wgpu::BindingResource::TextureView(&metallic_texture.view),
-                        },
-                        wgpu::BindGroupEntry {
-                            binding: 8,
-                            resource: wgpu::BindingResource::Sampler(&metallic_texture.sampler),
-                        },
-                        wgpu::BindGroupEntry {
-                            binding: 9,
-                            resource: wgpu::BindingResource::TextureView(&occlusion_texture.view),
-                        },
-                        wgpu::BindGroupEntry {
-                            binding: 10,
-                            resource: wgpu::BindingResource::Sampler(&occlusion_texture.sampler),
-                        },
-                    ],
-                });
-
-            self.per_material = Some(bind_group);
-        }
-
-        self.per_material.as_ref().unwrap()
-    }
-
     pub fn animation_bind_group(
         &self,
         graphics: Arc<SharedGraphicsContext>,
@@ -231,6 +161,8 @@ impl MainRenderPipeline {
         morph_weights_buffer: &wgpu::Buffer,
         morph_info_buffer: &wgpu::Buffer,
     ) -> wgpu::BindGroup {
+        
+
         graphics
             .device
             .create_bind_group(&wgpu::BindGroupDescriptor {
diff --git a/crates/eucalyptus-core/Cargo.toml b/crates/eucalyptus-core/Cargo.toml
index 45222f8..530276f 100644
--- a/crates/eucalyptus-core/Cargo.toml
+++ b/crates/eucalyptus-core/Cargo.toml
@@ -52,6 +52,7 @@ rkyv.workspace = true
 bitflags.workspace = true
 uuid.workspace = true
 splines.workspace = true
+puffin.workspace = true
 
 [features]
 default = []
diff --git a/crates/eucalyptus-core/src/lib.rs b/crates/eucalyptus-core/src/lib.rs
index 933cda9..02d72ad 100644
--- a/crates/eucalyptus-core/src/lib.rs
+++ b/crates/eucalyptus-core/src/lib.rs
@@ -30,6 +30,7 @@ pub mod types;
 pub mod ui;
 pub mod utils;
 pub mod uuid;
+pub mod rendering;
 
 pub use dropbear_macro as macros;
 
diff --git a/crates/eucalyptus-core/src/rendering.rs b/crates/eucalyptus-core/src/rendering.rs
new file mode 100644
index 0000000..640e022
--- /dev/null
+++ b/crates/eucalyptus-core/src/rendering.rs
@@ -0,0 +1,641 @@
+use std::collections::HashMap;
+use std::hash::{DefaultHasher, Hash, Hasher};
+use std::sync::Arc;
+use hecs::{Entity, World};
+use glam::{Mat4, Quat, Vec3};
+use dropbear_engine::animation::{AnimationComponent, MorphTargetInfo};
+use dropbear_engine::asset::{Handle, ASSET_REGISTRY};
+use dropbear_engine::billboarding::BillboardPipeline;
+use dropbear_engine::buffer::ResizableBuffer;
+use dropbear_engine::camera::Camera;
+use dropbear_engine::entity::{EntityTransform, MeshRenderer, Transform};
+use dropbear_engine::graphics::{CommandEncoder, InstanceRaw, SharedGraphicsContext};
+use dropbear_engine::lighting::Light;
+use dropbear_engine::model::{DrawLight, DrawModel, Material, Mesh, Model};
+use dropbear_engine::pipelines::DropbearShaderPipeline;
+use dropbear_engine::pipelines::animation::AnimationDefaults;
+use dropbear_engine::pipelines::hdr::HdrPipeline;
+use dropbear_engine::pipelines::light_cube::LightCubePipeline;
+use dropbear_engine::pipelines::shader::MainRenderPipeline;
+use dropbear_engine::sky::SkyPipeline;
+use kino_ui::KinoState;
+use crate::billboard::BillboardComponent;
+use crate::debug::DebugDrawExt;
+use crate::entity_status::EntityStatus;
+use crate::hierarchy::EntityTransformExt;
+use crate::physics::collider::ColliderGroup;
+use crate::states::SCENES;
+
+pub struct RenderInstance {
+    pub entity: Entity,
+    pub instance: InstanceRaw,
+    pub animation: Option<AnimationBuffers>,
+}
+
+pub struct AnimationBuffers {
+    pub skinning:      wgpu::Buffer,
+    pub morph_weights: wgpu::Buffer,
+    pub morph_info:    wgpu::Buffer,
+    pub weight_count:  u32,
+}
+
+pub struct ModelBatch {
+    pub model_id:  u64,
+    pub instances: Vec<RenderInstance>,
+}
+
+pub struct PreparedModel {
+    pub model: Arc<Model>,
+    pub handle_id: u64,
+    pub instance_count: u32,
+    pub entity: Option<Entity>,
+}
+
+/// Just common rendering functions that are shared between redback-runtime and eucalyptus-editor.
+pub struct RendererCommon;
+
+impl RendererCommon {
+    pub fn clear_viewport(graphics: &SharedGraphicsContext, encoder: &mut CommandEncoder, hdr: &HdrPipeline) {
+        puffin::profile_scope!("Clearing viewport");
+        let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+            label: Some("viewport clear pass"),
+            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+                view: hdr.render_view(),
+                depth_slice: None,
+                resolve_target: hdr.resolve_target(),
+                ops: wgpu::Operations {
+                    load: wgpu::LoadOp::Clear(wgpu::Color {
+                        r: 100.0 / 255.0,
+                        g: 149.0 / 255.0,
+                        b: 237.0 / 255.0,
+                        a: 1.0,
+                    }),
+                    store: wgpu::StoreOp::Store,
+                },
+            })],
+            depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
+                view: &graphics.depth_texture.view,
+                depth_ops: Some(wgpu::Operations {
+                    load: wgpu::LoadOp::Clear(0.0),
+                    store: wgpu::StoreOp::Store,
+                }),
+                stencil_ops: None,
+            }),
+            occlusion_query_set: None,
+            timestamp_writes: None,
+            multiview_mask: None,
+        });
+    }
+
+    pub fn collect_lights(world: &World) -> (Vec<Light>, u32) {
+        puffin::profile_scope!("Locating lights");
+        let mut lights = Vec::new();
+        let mut enabled = 0u32;
+        for light in world.query::<&Light>().iter() {
+            if light.component.enabled { enabled += 1; }
+            lights.push(light.clone());
+        }
+        (lights, enabled)
+    }
+
+    pub fn locate_renderers(
+        world: &World,
+        batches: &mut HashMap<u64, ModelBatch>,
+        graphics: Arc<SharedGraphicsContext>,
+        default_skinning_buffer: &Option<wgpu::Buffer>,
+    ) {
+        puffin::profile_scope!("finding all renderers");
+
+        let mut query = world.query::<(Entity, &MeshRenderer, Option<&mut AnimationComponent>)>();
+
+        for (entity, renderer, animation) in query.iter() {
+            if let Ok(status) = world.get::<&EntityStatus>(entity) {
+                if status.hidden || status.disabled { continue; }
+            }
+
+            let handle = renderer.model();
+            if handle.is_null() { continue; }
+
+            let instance_raw = renderer.instance.to_raw();
+
+            let animation_buffers = Self::resolve_animation_buffers(
+                graphics.clone(),
+                default_skinning_buffer,
+                animation
+            );
+
+            batches
+                .entry(handle.id)
+                .or_insert_with(|| ModelBatch { model_id: handle.id, instances: Vec::new() })
+                .instances
+                .push(RenderInstance {
+                    entity,
+                    instance: instance_raw,
+                    animation: animation_buffers,
+                });
+        }
+    }
+
+    fn resolve_animation_buffers(
+        graphics: Arc<SharedGraphicsContext>,
+        default_skinning_buffer: &Option<wgpu::Buffer>,
+        animation: Option<&mut AnimationComponent>,
+    ) -> Option<AnimationBuffers> {
+        let anim = animation?;
+
+        let has_skinning = !anim.skinning_matrices.is_empty();
+        let has_morph    = !anim.morph_weights.is_empty();
+
+        if !has_skinning && !has_morph {
+            return None;
+        }
+
+        anim.prepare_gpu_resources(graphics.clone());
+
+        let skinning = anim
+            .skinning_buffer
+            .as_ref()
+            .and_then(|b| Some(b.buffer().clone()))
+            .or_else(|| default_skinning_buffer.clone())?;
+
+        let morph_weights = anim.morph_weights_buffer
+            .as_ref()
+            .map(|b| b.buffer().clone())?;
+
+        let morph_info = anim.morph_info_buffer
+            .as_ref()
+            .map(|b| b.buffer().clone())?;
+
+        Some(AnimationBuffers {
+            skinning,
+            morph_weights,
+            morph_info,
+            weight_count: anim.morph_weight_count,
+        })
+    }
+
+    pub fn prepare_models(
+        graphics: &SharedGraphicsContext,
+        batches: &HashMap<u64, ModelBatch>,
+        instance_buffer_cache: &mut HashMap<u64, ResizableBuffer<InstanceRaw>>,
+    ) -> (Vec<PreparedModel>, HashMap<u64, Arc<Model>>) {
+        puffin::profile_scope!("preparing models");
+        let registry = ASSET_REGISTRY.read();
+        let mut model_cache = HashMap::new();
+        let mut prepared = Vec::new();
+
+        for (handle_id, batch) in batches {
+            let static_instances: Vec<_> = batch.instances.iter()
+                .filter(|i| i.animation.is_none())
+                .collect();
+            if static_instances.is_empty() { continue; }
+
+            let Some(model) = registry.get_model(Handle::new(*handle_id)) else {
+                log_once::error_once!("Missing model handle {} in registry", handle_id);
+                continue;
+            };
+
+            let instances: Vec<InstanceRaw> = static_instances.iter()
+                .map(|i| i.instance)
+                .collect();
+            let entity = static_instances.first().map(|i| i.entity);
+
+            let instance_buffer = instance_buffer_cache
+                .entry(*handle_id)
+                .or_insert_with(|| ResizableBuffer::new(
+                    &graphics.device,
+                    instances.len().max(1),
+                    wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
+                    "Runtime Instance Buffer",
+                ));
+            instance_buffer.write(&graphics.device, &graphics.queue, &instances);
+
+            model_cache.insert(*handle_id, model.clone());
+            prepared.push(PreparedModel {
+                model,
+                handle_id: *handle_id,
+                instance_count: instances.len() as u32,
+                entity,
+            });
+        }
+
+        // also cache models needed for animated instances
+        for batch in batches.values() {
+            for inst in &batch.instances {
+                if inst.animation.is_some() && !model_cache.contains_key(&batch.model_id) {
+                    if let Some(model) = registry.get_model(Handle::new(batch.model_id)) {
+                        model_cache.insert(batch.model_id, model);
+                    }
+                }
+            }
+        }
+
+        (prepared, model_cache)
+    }
+
+    pub fn render_light_cubes(
+        graphics: &Arc<SharedGraphicsContext>,
+        encoder: &mut CommandEncoder,
+        hdr: &HdrPipeline,
+        lights: &[Light],
+        camera: &Camera,
+
+        light_cube_pipeline: Option<&LightCubePipeline>,
+    ) {
+        let (Some(light_pipeline), Some(l)) = (&light_cube_pipeline, lights.first()) else { return };
+        let Some(model) = ASSET_REGISTRY.read().get_model(l.cube_model) else {
+            log_once::error_once!("Missing light cube model handle in registry");
+            return;
+        };
+
+        puffin::profile_scope!("light cube pass");
+        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+            label: Some("light cube 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,
+            }),
+            occlusion_query_set: None,
+            timestamp_writes: None,
+            multiview_mask: None,
+        });
+        pass.set_pipeline(light_pipeline.pipeline());
+        for light in lights {
+            puffin::profile_scope!("rendering light", &light.label);
+            pass.set_vertex_buffer(1, light.instance_buffer.buffer().slice(..));
+            if !light.component.visible { continue; }
+            pass.draw_light_model(&model, &camera.bind_group, &light.bind_group);
+        }
+    }
+
+    pub fn render_models(
+        graphics: &Arc<SharedGraphicsContext>,
+        encoder: &mut CommandEncoder,
+        hdr: &HdrPipeline,
+        world: &World,
+        batches: &HashMap<u64, ModelBatch>,
+        model_cache: &HashMap<u64, Arc<Model>>,
+        per_frame_bind_group: &wgpu::BindGroup,
+        environment_bind_group: &wgpu::BindGroup,
+        pipeline: &MainRenderPipeline,
+        animation_defaults: &AnimationDefaults,
+        instance_buffer_cache: &HashMap<u64, ResizableBuffer<InstanceRaw>>,
+        animated_instance_buffers: &mut HashMap<Entity, ResizableBuffer<InstanceRaw>>,
+        animated_bind_group_cache: &mut HashMap<Entity, (u64, wgpu::BindGroup)>,
+        static_bind_group_cache: &mut HashMap<u64, wgpu::BindGroup>,
+        last_morph_info_per_mesh: &mut HashMap<u32, MorphTargetInfo>,
+    ) {
+        puffin::profile_scope!("model render pass");
+
+        for (_, batch) in batches {
+            let Some(model) = model_cache.get(&batch.model_id) else { continue };
+
+            let static_count = batch.instances.iter().filter(|i| i.animation.is_none()).count() as u32;
+            if static_count > 0 {
+                let Some(first) = batch.instances.iter().find(|i| i.animation.is_none()) else { continue };
+                let Ok(renderer) = world.get::<&MeshRenderer>(first.entity) else { continue };
+
+                if let Some(deltas) = model.morph_deltas_buffer.as_ref() {
+                    if !static_bind_group_cache.contains_key(&batch.model_id) {
+                        let bg = graphics.device.create_bind_group(&wgpu::BindGroupDescriptor {
+                            label: Some("static model animation bind group"),
+                            layout: &graphics.layouts.animation_layout,
+                            entries: &[
+                                wgpu::BindGroupEntry { binding: 0, resource: animation_defaults.skinning_buffer.buffer().as_entire_binding() },
+                                wgpu::BindGroupEntry { binding: 1, resource: deltas.as_entire_binding() },
+                                wgpu::BindGroupEntry { binding: 2, resource: animation_defaults.morph_weights_buffer.buffer().as_entire_binding() },
+                                wgpu::BindGroupEntry { binding: 3, resource: animation_defaults.morph_info_buffer.buffer().as_entire_binding() },
+                            ],
+                        });
+                        static_bind_group_cache.insert(batch.model_id, bg);
+                    }
+                }
+
+                let animation_bg: &wgpu::BindGroup = if model.morph_deltas_buffer.is_some() {
+                    static_bind_group_cache.get(&batch.model_id).unwrap()
+                } else {
+                    &animation_defaults.animation_bind_group
+                };
+
+                let Some(instance_buffer) = instance_buffer_cache.get(&batch.model_id) else { continue };
+
+                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+                    label: Some("model 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,
+                    }),
+                    occlusion_query_set: None,
+                    timestamp_writes: None,
+                    multiview_mask: None,
+                });
+                pass.set_pipeline(pipeline.pipeline());
+                pass.set_vertex_buffer(1, instance_buffer.slice(static_count as usize));
+
+                for mesh in &model.meshes {
+                    let mut weights = mesh.morph_default_weights.clone();
+                    let target_count = mesh.morph_target_count as usize;
+                    if weights.len() < target_count { weights.resize(target_count, 0.0); }
+                    if weights.is_empty() { weights.push(0.0); }
+
+                    graphics.queue.write_buffer(
+                        animation_defaults.morph_weights_buffer.buffer(), 0,
+                        bytemuck::cast_slice(&weights),
+                    );
+
+                    let info = MorphTargetInfo {
+                        num_vertices: mesh.morph_vertex_count,
+                        num_targets: mesh.morph_target_count,
+                        base_offset: mesh.morph_deltas_offset,
+                        weight_offset: 0,
+                        uses_morph: if mesh.morph_target_count > 0 && !weights.is_empty() { 1 } else { 0 },
+                        _padding: Default::default(),
+                    };
+
+                    let cache_key = mesh.morph_deltas_offset;
+                    let needs_write = last_morph_info_per_mesh.get(&cache_key).map_or(true, |prev| {
+                        prev.num_vertices != info.num_vertices
+                            || prev.num_targets != info.num_targets
+                            || prev.base_offset != info.base_offset
+                            || prev.uses_morph != info.uses_morph
+                    });
+                    if needs_write {
+                        graphics.queue.write_buffer(
+                            animation_defaults.morph_info_buffer.buffer(), 0,
+                            bytemuck::bytes_of(&info),
+                        );
+                        last_morph_info_per_mesh.insert(cache_key, info);
+                    }
+
+                    let material = Self::resolve_material(model, mesh, &renderer);
+                    pass.draw_mesh_instanced(mesh, material, 0..static_count, per_frame_bind_group, animation_bg, environment_bind_group);
+                }
+            }
+
+            for inst in batch.instances.iter().filter(|i| i.animation.is_some()) {
+                puffin::profile_scope!("rendering animated model", format!("{:?}", inst.entity));
+                let anim = inst.animation.as_ref().unwrap();
+
+                {
+                    let buf = animated_instance_buffers.entry(inst.entity).or_insert_with(|| {
+                        ResizableBuffer::new(
+                            &graphics.device, 1,
+                            wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
+                            "animated instance buffer",
+                        )
+                    });
+                    buf.write(&graphics.device, &graphics.queue, &[inst.instance]);
+                }
+
+                let Ok(renderer) = world.get::<&MeshRenderer>(inst.entity) else { continue };
+
+                let morph_deltas = model.morph_deltas_buffer.as_ref()
+                    .map(|b| b as &wgpu::Buffer)
+                    .unwrap_or_else(|| animation_defaults.morph_deltas_buffer.buffer());
+
+                let mut hasher = DefaultHasher::new();
+                anim.skinning.hash(&mut hasher);
+                let stamp = hasher.finish();
+
+                {
+                    let cached = animated_bind_group_cache.get(&inst.entity);
+                    if cached.map_or(true, |(s, _)| *s != stamp) {
+                        let bg = pipeline.animation_bind_group(
+                            graphics.clone(),
+                            &anim.skinning,
+                            morph_deltas,
+                            &anim.morph_weights,
+                            &anim.morph_info,
+                        );
+                        animated_bind_group_cache.insert(inst.entity, (stamp, bg));
+                    }
+                }
+
+                let animation_bg = &animated_bind_group_cache[&inst.entity].1;
+                let instance_buffer = &animated_instance_buffers[&inst.entity];
+
+                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+                    label: Some("animated model 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,
+                    }),
+                    occlusion_query_set: None,
+                    timestamp_writes: None,
+                    multiview_mask: None,
+                });
+                pass.set_pipeline(pipeline.pipeline());
+                pass.set_vertex_buffer(1, instance_buffer.slice(1));
+
+                for mesh in &model.meshes {
+                    let mesh_target_count = mesh.morph_target_count.min(anim.weight_count);
+                    let info = MorphTargetInfo {
+                        num_vertices: mesh.morph_vertex_count,
+                        num_targets: mesh_target_count,
+                        base_offset: mesh.morph_deltas_offset,
+                        weight_offset: 0,
+                        uses_morph: if mesh_target_count > 0 { 1 } else { 0 },
+                        _padding: Default::default(),
+                    };
+                    graphics.queue.write_buffer(&anim.morph_info, 0, bytemuck::bytes_of(&info));
+
+                    let material = Self::resolve_material(model, mesh, &renderer);
+                    pass.draw_mesh_instanced(mesh, material, 0..1, per_frame_bind_group, animation_bg, environment_bind_group);
+                }
+            }
+        }
+    }
+
+    pub fn render_sky(
+        graphics: &SharedGraphicsContext,
+        encoder: &mut CommandEncoder,
+        hdr: &HdrPipeline,
+        sky: &SkyPipeline,
+    ) {
+        puffin::profile_scope!("sky render pass");
+        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+            label: Some("sky 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,
+            multiview_mask: None,
+        });
+        pass.set_pipeline(&sky.pipeline);
+        pass.set_bind_group(0, &sky.camera_bind_group, &[]);
+        pass.set_bind_group(1, &sky.environment_bind_group, &[]);
+        pass.draw(0..3, 0..1);
+    }
+
+    pub fn render_collider_debug(
+        graphics: &SharedGraphicsContext,
+        world: &World,
+        current_scene_name: Option<&str>,
+    ) {
+        let show_hitboxes = current_scene_name
+            .and_then(|scene_name| {
+                let scenes = SCENES.read();
+                scenes.iter()
+                    .find(|s| s.scene_name == scene_name)
+                    .map(|s| s.settings.show_hitboxes)
+            })
+            .unwrap_or(false);
+
+        if !show_hitboxes { return; }
+
+        puffin::profile_scope!("collider debug draw");
+        if let Some(debug_draw) = graphics.debug_draw.lock().as_mut() {
+            let colour = [0.0, 1.0, 0.0, 1.0];
+            let to_draw: Vec<_> = {
+                let mut q = world.query::<(Entity, &ColliderGroup)>();
+                q.iter().map(|(e, cg)| (e, cg.colliders.clone())).collect()
+            };
+            for (entity, colliders) in to_draw {
+                let Ok(et) = world.get::<&EntityTransform>(entity) else { continue };
+                let world_tf = et.propagate(world, entity);
+                drop(et);
+                for collider in &colliders {
+                    let entity_matrix = world_tf.matrix().as_mat4();
+                    let offset_transform = Transform::new().with_offset(collider.translation, collider.rotation);
+                    let offset_matrix = offset_transform.matrix().as_mat4();
+                    let final_matrix = entity_matrix * offset_matrix;
+                    let (scale, rotation, translation) = final_matrix.to_scale_rotation_translation();
+                    debug_draw.draw_collider(&collider.shape, translation, scale, rotation, colour);
+                }
+            }
+        }
+    }
+
+    pub fn render_billboards(
+        graphics: &Arc<SharedGraphicsContext>,
+        encoder: &mut CommandEncoder,
+        hdr: &HdrPipeline,
+        camera: &Camera,
+        world: &World,
+        kino: Option<&mut KinoState>,
+        billboard_pipeline: Option<&BillboardPipeline>,
+    ) {
+        puffin::profile_scope!("rendering billboard targets");
+
+        let mut kino_views: HashMap<u64, wgpu::TextureView> = HashMap::new();
+
+        if let Some(kino) = 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);
+            }
+            kino_views.extend(kino.billboard_render_target_views());
+        }
+
+        let Some(billboard_pipeline) = billboard_pipeline else { return };
+
+        let camera_position = camera.position().as_vec3();
+        let camera_projection = Mat4::from_cols_array_2d(&camera.uniform.view_proj);
+
+        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 = world.query::<(Entity, &BillboardComponent, Option<&EntityTransform>)>();
+
+        for (entity, billboard, entity_transform) in query.iter() {
+            puffin::profile_scope!("rendering billboard", format!("{:?}", entity));
+            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(|t| t.sync().position.as_vec3())
+                .unwrap_or(Vec3::ZERO)
+                + billboard.offset;
+            let scale = Vec3::new(billboard.world_size.x, billboard.world_size.y, 1.0);
+
+            let rotation = if let Some(r) = billboard.rotation {
+                r
+            } 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();
+                    Quat::from_mat3(&glam::Mat3::from_cols(right, up, to_camera))
+                } else {
+                    Quat::IDENTITY
+                }
+            };
+
+            billboards.push((Mat4::from_scale_rotation_translation(scale, rotation, position), texture_view));
+        }
+
+        if billboards.is_empty() { return; }
+
+        puffin::profile_scope!("billboard render pass");
+        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+            label: Some("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,
+            multiview_mask: None,
+        });
+
+        for (transform, texture_view) in billboards {
+            billboard_pipeline.draw(graphics.clone(), &mut pass, transform, camera_projection, &texture_view);
+        }
+    }
+
+    fn resolve_material<'a>(model: &'a Model, mesh: &Mesh, renderer: &'a MeshRenderer) -> &'a Material {
+        let material = &model.materials[mesh.material];
+        renderer.material_snapshot.get(&material.name).unwrap_or_else(|| {
+            log_once::warn_once!("Unable to locate MeshRenderer's material_snapshot for that specific material");
+            material
+        })
+    }
+}
\ No newline at end of file
diff --git a/crates/eucalyptus-editor/src/editor/mod.rs b/crates/eucalyptus-editor/src/editor/mod.rs
index 1a02a09..e8762cb 100644
--- a/crates/eucalyptus-editor/src/editor/mod.rs
+++ b/crates/eucalyptus-editor/src/editor/mod.rs
@@ -73,6 +73,8 @@ use wgpu::{Color, Extent3d};
 use winit::dpi::PhysicalSize;
 use winit::window::{CursorGrabMode, WindowAttributes};
 use winit::{keyboard::KeyCode, window::Window};
+use dropbear_engine::model::Animation;
+use dropbear_engine::pipelines::animation::AnimationDefaults;
 
 pub struct Editor {
     pub dt: f32,
@@ -84,7 +86,6 @@ pub struct Editor {
     pub texture_id: Option<egui::TextureId>,
     pub size: Extent3d,
     pub instance_buffer_cache: HashMap<u64, ResizableBuffer<InstanceRaw>>,
-    pub animated_instance_buffers: HashMap<Entity, ResizableBuffer<InstanceRaw>>,
     pub color: Color,
 
     pub ui_editor: UiEditor,
@@ -99,22 +100,10 @@ pub struct Editor {
     pub sky_pipeline: Option<SkyPipeline>,
     pub billboard_pipeline: Option<BillboardPipeline>,
     pub kino: Option<KinoState>,
-    pub(crate) default_skinning_buffer: Option<wgpu::Buffer>,
-    pub(crate) default_morph_deltas_buffer: Option<wgpu::Buffer>,
-    pub(crate) default_morph_weights_buffer: Option<wgpu::Buffer>,
-    pub(crate) default_morph_info_buffer: Option<wgpu::Buffer>,
-    pub(crate) default_animation_bind_group: Option<wgpu::BindGroup>,
-    pub(crate) static_batches: HashMap<u64, Vec<(Entity, InstanceRaw)>>,
-    pub(crate) animated_instances: Vec<(
-        Entity,
-        u64,
-        InstanceRaw,
-        wgpu::Buffer,
-        wgpu::Buffer,
-        wgpu::Buffer,
-        u32,
-    )>,
+    pub animation_pipeline: Option<AnimationDefaults>,
+    pub(crate) animated_instance_buffers: HashMap<Entity, ResizableBuffer<InstanceRaw>>,
     pub(crate) animated_bind_group_cache: HashMap<Entity, (u64, wgpu::BindGroup)>,
+    pub(crate) static_bind_group_cache: HashMap<u64, wgpu::BindGroup>,
     pub(crate) last_morph_info_per_mesh: HashMap<u32, MorphTargetInfo>, // key = morph_deltas_offset
 
     pub active_camera: Arc<Mutex<Option<Entity>>>,
@@ -326,19 +315,14 @@ impl Editor {
             asset_clipboard: None,
             pending_aa_reload: None,
             instance_buffer_cache: HashMap::new(),
-            animated_instance_buffers: HashMap::new(),
             mipmapper: None,
             sky_pipeline: None,
             billboard_pipeline: None,
             kino: None,
-            default_skinning_buffer: None,
-            default_morph_deltas_buffer: None,
-            default_morph_weights_buffer: None,
-            default_morph_info_buffer: None,
-            default_animation_bind_group: None,
-            static_batches: Default::default(),
-            animated_instances: vec![],
+            animation_pipeline: None,
+            animated_instance_buffers: Default::default(),
             animated_bind_group_cache: Default::default(),
+            static_bind_group_cache: Default::default(),
             dt: 60.0,
             ui_editor_dock_state: DockState::new(vec![]),
             current_page: EditorTabVisibility::GameEditor,
@@ -1664,11 +1648,7 @@ impl Editor {
             KinoWinitWindowing::new(graphics.window.clone(), None),
         ));
 
-        self.default_skinning_buffer = None;
-        self.default_morph_deltas_buffer = None;
-        self.default_morph_weights_buffer = None;
-        self.default_morph_info_buffer = None;
-        self.default_animation_bind_group = None;
+        self.animation_pipeline = None;
 
         self.texture_id = Some((*graphics.texture_id).clone());
         self.window = Some(graphics.window.clone());
@@ -1679,92 +1659,7 @@ impl Editor {
         let active_camera = self.active_camera.lock().clone();
         if let Some(camera_entity) = active_camera {
             if let Ok(camera) = self.world.query_one::<&Camera>(camera_entity).get() {
-                let max_skinning_matrices = 256usize;
-                let identity = vec![Mat4::IDENTITY; max_skinning_matrices];
-                let skinning_buffer =
-                    graphics
-                        .device
-                        .create_buffer_init(&wgpu::util::BufferInitDescriptor {
-                            label: Some("editor default skinning buffer"),
-                            contents: bytemuck::cast_slice(&identity),
-                            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
-                        });
-
-                let morph_deltas_buffer =
-                    graphics
-                        .device
-                        .create_buffer_init(&wgpu::util::BufferInitDescriptor {
-                            label: Some("editor default morph deltas buffer"),
-                            contents: bytemuck::cast_slice(&[0.0f32]),
-                            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
-                        });
-
-                let morph_weights = vec![0.0f32; MAX_MORPH_WEIGHTS];
-                let morph_weights_buffer =
-                    graphics
-                        .device
-                        .create_buffer_init(&wgpu::util::BufferInitDescriptor {
-                            label: Some("editor default morph weights buffer"),
-                            contents: bytemuck::cast_slice(&morph_weights),
-                            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
-                        });
-
-                let morph_info = dropbear_engine::animation::MorphTargetInfo::default();
-                let morph_info_buffer =
-                    graphics
-                        .device
-                        .create_buffer_init(&wgpu::util::BufferInitDescriptor {
-                            label: Some("editor default morph info buffer"),
-                            contents: bytemuck::bytes_of(&morph_info),
-                            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
-                        });
-
-                self.default_skinning_buffer = Some(skinning_buffer);
-                self.default_morph_deltas_buffer = Some(morph_deltas_buffer);
-                self.default_morph_weights_buffer = Some(morph_weights_buffer);
-                self.default_morph_info_buffer = Some(morph_info_buffer);
-
-                let skinning_buffer = self
-                    .default_skinning_buffer
-                    .as_ref()
-                    .expect("Default skinning buffer missing");
-                let morph_deltas_buffer = self
-                    .default_morph_deltas_buffer
-                    .as_ref()
-                    .expect("Default morph deltas buffer missing");
-                let morph_weights_buffer = self
-                    .default_morph_weights_buffer
-                    .as_ref()
-                    .expect("Default morph weights buffer missing");
-                let morph_info_buffer = self
-                    .default_morph_info_buffer
-                    .as_ref()
-                    .expect("Default morph info buffer missing");
-
-                self.default_animation_bind_group = Some(graphics.device.create_bind_group(
-                    &wgpu::BindGroupDescriptor {
-                        label: Some("editor default animation bind group"),
-                        layout: &graphics.layouts.animation_layout,
-                        entries: &[
-                            wgpu::BindGroupEntry {
-                                binding: 0,
-                                resource: skinning_buffer.as_entire_binding(),
-                            },
-                            wgpu::BindGroupEntry {
-                                binding: 1,
-                                resource: morph_deltas_buffer.as_entire_binding(),
-                            },
-                            wgpu::BindGroupEntry {
-                                binding: 2,
-                                resource: morph_weights_buffer.as_entire_binding(),
-                            },
-                            wgpu::BindGroupEntry {
-                                binding: 3,
-                                resource: morph_info_buffer.as_entire_binding(),
-                            },
-                        ],
-                    },
-                ));
+                self.animation_pipeline = Some(AnimationDefaults::new(graphics.clone()));
 
                 if let Some(main_pipeline) = self.main_render_pipeline.as_mut() {
                     if let (Some(globals), Some(light_pipeline)) = (
diff --git a/crates/eucalyptus-editor/src/editor/scene.rs b/crates/eucalyptus-editor/src/editor/scene.rs
index 11ca9bd..aba803e 100644
--- a/crates/eucalyptus-editor/src/editor/scene.rs
+++ b/crates/eucalyptus-editor/src/editor/scene.rs
@@ -2,22 +2,15 @@ use super::*;
 use crate::signal::SignalController;
 use crate::spawn::PendingSpawnController;
 use crossbeam_channel::unbounded;
-use dropbear_engine::animation::{AnimationComponent, MorphTargetInfo};
-use dropbear_engine::asset::{ASSET_REGISTRY, Handle};
-use dropbear_engine::buffer::ResizableBuffer;
-use dropbear_engine::graphics::{CommandEncoder, InstanceRaw};
+use dropbear_engine::graphics::CommandEncoder;
 use dropbear_engine::{
     entity::{EntityTransform, MeshRenderer, Transform},
     lighting::Light,
-    model::{DrawLight, DrawModel},
     scene::{Scene, SceneCommand},
 };
 use egui::UiBuilder;
 use eucalyptus_core::billboard::BillboardComponent;
 use eucalyptus_core::component::KotlinComponentDecl;
-use eucalyptus_core::entity_status::EntityStatus;
-use eucalyptus_core::hierarchy::EntityTransformExt;
-use eucalyptus_core::physics::collider::{ColliderGroup};
 use eucalyptus_core::properties::CustomProperties;
 use eucalyptus_core::states::{Label, SCENES, WorldLoadingStatus};
 use eucalyptus_core::transform::OnRails;
@@ -28,7 +21,6 @@ use log;
 use magna_carta::ScriptManifest;
 use parking_lot::Mutex;
 use std::collections::HashMap;
-use std::hash::{DefaultHasher, Hash, Hasher};
 use std::sync::Arc;
 use std::{
     fs,
@@ -36,7 +28,7 @@ use std::{
 };
 use winit::event::{MouseScrollDelta, TouchPhase};
 use winit::{event::WindowEvent, event_loop::ActiveEventLoop, keyboard::KeyCode};
-use eucalyptus_core::debug::DebugDrawExt;
+use eucalyptus_core::rendering::RendererCommon;
 
 impl Scene for Editor {
     fn load(&mut self, graphics: Arc<SharedGraphicsContext>) {
@@ -456,285 +448,41 @@ impl Scene for Editor {
         self.input_state.mouse_delta = None;
     }
 
-    fn render<'a>(&mut self, graphics: Arc<SharedGraphicsContext>) {
+    fn render(&mut self, graphics: Arc<SharedGraphicsContext>) {
         self.editor_specific_render(&graphics);
 
         let hdr = graphics.hdr.read();
-
         let mut encoder = CommandEncoder::new(graphics.clone(), Some("runtime viewport encoder"));
 
-        let active_camera = { self.active_camera.lock().as_ref().cloned() };
-        let Some(active_camera) = active_camera else {
-            return;
-        };
+        let Some(active_camera) = self.active_camera.lock().as_ref().cloned() else { return };
         log_once::debug_once!("Active camera found: {:?}", active_camera);
+        let Some(camera) = self.world.query_one::<&Camera>(active_camera).get().ok().cloned() else { return };
+        log_once::debug_once!("Camera ready: {}", camera.label);
 
-        let q = self
-            .world
-            .query_one::<&Camera>(active_camera)
-            .get()
-            .ok()
-            .cloned();
-
-        let Some(camera) = q else {
-            return;
-        };
-
-        log_once::debug_once!("Camera ready");
-        log_once::debug_once!("Camera currently being viewed: {}", camera.label);
-
-        // clear viewport render pass
-        {
-            puffin::profile_scope!("Clearing viewport");
-            let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
-                label: Some("viewport clear pass"),
-                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
-                    view: hdr.render_view(),
-                    depth_slice: None,
-                    resolve_target: hdr.resolve_target(),
-                    ops: wgpu::Operations {
-                        load: wgpu::LoadOp::Clear(wgpu::Color {
-                            r: 100.0 / 255.0,
-                            g: 149.0 / 255.0,
-                            b: 237.0 / 255.0,
-                            a: 1.0,
-                        }),
-                        store: wgpu::StoreOp::Store,
-                    },
-                })],
-                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
-                    view: &graphics.depth_texture.view,
-                    depth_ops: Some(wgpu::Operations {
-                        load: wgpu::LoadOp::Clear(0.0),
-                        store: wgpu::StoreOp::Store,
-                    }),
-                    stencil_ops: None,
-                }),
-                occlusion_query_set: None,
-                timestamp_writes: None,
-                multiview_mask: None,
-            });
-        }
+        RendererCommon::clear_viewport(&graphics, &mut encoder, &hdr);
 
-        if let Some(light_pipeline) = &mut self.light_cube_pipeline {
-            light_pipeline.update(graphics.clone(), &self.world);
+        if let Some(p) = &mut self.light_cube_pipeline {
+            p.update(graphics.clone(), &self.world);
         }
+        let (lights, enabled_light_count) = RendererCommon::collect_lights(&self.world);
 
-        let (lights, enabled_light_count) = {
-            puffin::profile_scope!("Locating lights");
-            let mut lights = Vec::new();
-            let mut enabled = 0u32;
-            for light in self.world.query::<&Light>().iter() {
-                if light.component.enabled {
-                    enabled += 1;
-                }
-                lights.push(light.clone());
-            }
-            (lights, enabled)
-        };
-
-        if let Some(globals) = &mut self.shader_globals {
-            puffin::profile_scope!("Fetching globals");
-            globals.set_num_lights(enabled_light_count);
+        {
+            let Some(globals) = &mut self.shader_globals else { return };
+            globals.data.num_lights = enabled_light_count;
             if let Some(scene_name) = &self.current_scene_name {
                 let scenes = SCENES.read();
                 if let Some(scene) = scenes.iter().find(|s| s.scene_name == *scene_name) {
-                    globals.set_ambient_strength(scene.settings.ambient_strength);
+                    globals.data.ambient_strength = scene.settings.ambient_strength;
                 }
             }
             globals.write(&graphics.queue);
         }
 
-        self.static_batches.clear();
-        self.animated_instances.clear();
-        {
-            puffin::profile_scope!("finding all renderers and animation components");
-            let mut query = self
-                .world
-                .query::<(Entity, &MeshRenderer, Option<&mut AnimationComponent>)>();
-
-            for (entity, renderer, animation) in query.iter() {
-                puffin::profile_scope!(format!("locating {:?}", entity));
-                let world_ptr = &*self.world as *const hecs::World;
-                let world_ref = unsafe { &*world_ptr };
-                if let Ok(status) = world_ref.get::<&EntityStatus>(entity) {
-                    if status.hidden || status.disabled {
-                        continue;
-                    }
-                }
-                let handle = renderer.model();
-                if handle.is_null() {
-                    continue;
-                }
-
-                let instance = renderer.instance.to_raw();
-
-                if let Some(animation) = animation {
-                    let has_skinning = !animation.skinning_matrices.is_empty();
-                    let has_morph = !animation.morph_weights.is_empty();
-
-                    if !has_skinning && !has_morph {
-                        self.static_batches
-                            .entry(handle.id)
-                            .or_default()
-                            .push((entity, instance));
-                        continue;
-                    }
-
-                    animation.prepare_gpu_resources(graphics.clone());
-
-                    let skinning_buffer = match animation
-                        .skinning_buffer
-                        .as_ref()
-                        .map(|b| b.buffer().clone())
-                    {
-                        Some(buf) => buf,
-                        None if !has_skinning => {
-                            let Some(default) = self.default_skinning_buffer.as_ref() else {
-                                self.static_batches
-                                    .entry(handle.id)
-                                    .or_default()
-                                    .push((entity, instance));
-                                continue;
-                            };
-                            default.clone()
-                        }
-                        None => {
-                            self.static_batches
-                                .entry(handle.id)
-                                .or_default()
-                                .push((entity, instance));
-                            continue;
-                        }
-                    };
-
-                    let Some(morph_weights_buffer) = animation
-                        .morph_weights_buffer
-                        .as_ref()
-                        .map(|b| b.buffer().clone())
-                    else {
-                        self.static_batches
-                            .entry(handle.id)
-                            .or_default()
-                            .push((entity, instance));
-                        continue;
-                    };
-
-                    let Some(morph_info_buffer) = animation
-                        .morph_info_buffer
-                        .as_ref()
-                        .map(|b| b.buffer().clone())
-                    else {
-                        self.static_batches
-                            .entry(handle.id)
-                            .or_default()
-                            .push((entity, instance));
-                        continue;
-                    };
-
-                    self.animated_instances.push((
-                        entity,
-                        handle.id,
-                        instance,
-                        skinning_buffer,
-                        morph_weights_buffer,
-                        morph_info_buffer,
-                        animation.morph_weight_count,
-                    ));
-                } else {
-                    self.static_batches
-                        .entry(handle.id)
-                        .or_default()
-                        .push((entity, instance));
-                }
-            }
-        }
-
-        let registry = ASSET_REGISTRY.read();
-
-        let mut model_cache: HashMap<u64, _> = HashMap::new();
-        let mut prepared_models = Vec::new();
-        for (handle, batched_instances) in &self.static_batches {
-            puffin::profile_scope!("preparing models");
-            let Some(model) = registry.get_model(Handle::new(*handle)) else {
-                log_once::error_once!("Missing model handle {} in registry", handle);
-                continue;
-            };
-
-            let entity = batched_instances.first().map(|(e, _)| *e);
-            let instances: Vec<InstanceRaw> =
-                batched_instances.iter().map(|(_, inst)| *inst).collect();
-
-            let instance_buffer = self
-                .instance_buffer_cache
-                .entry(*handle)
-                .or_insert_with(|| {
-                    ResizableBuffer::new(
-                        &graphics.device,
-                        instances.len().max(1),
-                        wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-                        "Runtime Instance Buffer",
-                    )
-                });
-            instance_buffer.write(&graphics.device, &graphics.queue, &instances);
-
-            model_cache.insert(*handle, model.clone());
-            prepared_models.push((model, *handle, instances.len() as u32, entity));
-        }
-
-        for (_, handle, ..) in &self.animated_instances {
-            if !model_cache.contains_key(handle) {
-                if let Some(model) = registry.get_model(Handle::new(*handle)) {
-                    model_cache.insert(*handle, model);
-                }
-            }
-        }
+        let mut batches = HashMap::new();
+        let default_skinning = self.animation_pipeline.as_ref().map(|p| p.skinning_buffer.buffer().clone());
+        RendererCommon::locate_renderers(&self.world, &mut batches, graphics.clone(), &default_skinning);
 
-        // light cube rendering
-        if let Some(light_pipeline) = &self.light_cube_pipeline {
-            if let Some(l) = lights.first()
-                && let Some(model) = registry.get_model(l.cube_model)
-            {
-                {
-                    puffin::profile_scope!("light cube pass");
-                    let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
-                        label: Some("light cube 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,
-                        }),
-                        occlusion_query_set: None,
-                        timestamp_writes: None,
-                        multiview_mask: None,
-                    });
-
-                    render_pass.set_pipeline(light_pipeline.pipeline());
-                    for light in &lights {
-                        puffin::profile_scope!("rendering light", &light.label);
-                        render_pass.set_vertex_buffer(1, light.instance_buffer.buffer().slice(..));
-                        if !light.component.visible {
-                            continue;
-                        }
-                        render_pass.draw_light_model(&model, &camera.bind_group, &light.bind_group);
-                    }
-                }
-            } else {
-                log_once::error_once!("Missing light cube model handle in registry",);
-            }
-        }
+        let (_, model_cache) = RendererCommon::prepare_models(&graphics, &batches, &mut self.instance_buffer_cache);
 
         if self.last_active_camera_for_per_frame != Some(active_camera) {
             self.last_active_camera_for_per_frame = Some(active_camera);
@@ -753,525 +501,62 @@ impl Scene for Editor {
             }
         }
 
-        let sky = self
-            .sky_pipeline
-            .as_ref()
-            .expect("Sky pipeline must be initialised before rendering models");
+        let sky = self.sky_pipeline.as_ref().expect("Sky pipeline must be initialised");
         let environment_bind_group = &sky.environment_bind_group;
 
-        let Some(pipeline) = self.main_render_pipeline.as_mut() else {
+        let Some(pipeline) = self.main_render_pipeline.as_ref() else {
             log_once::warn_once!("Render pipeline not ready");
             return;
         };
-        log_once::debug_once!("Pipeline ready");
-
-        // static models
-        if let Some(_) = &self.light_cube_pipeline {
-            puffin::profile_scope!("model render pass");
-
-            let default_skinning_buffer = self
-                .default_skinning_buffer
-                .as_ref()
-                .expect("Default skinning buffer not initialised");
-            let default_morph_weights_buffer = self
-                .default_morph_weights_buffer
-                .as_ref()
-                .expect("Default morph weights buffer not initialised");
-            let default_morph_info_buffer = self
-                .default_morph_info_buffer
-                .as_ref()
-                .expect("Default morph info buffer not initialised");
-            let per_frame_bind_group = pipeline
-                .per_frame
-                .as_ref()
-                .expect("Per-frame bind group not initialised")
-                .clone();
-
-            for (model, handle, instance_count, entity) in prepared_models {
-                let Some(entity) = entity else { continue };
-                let Ok(renderer) = self.world.get::<&MeshRenderer>(entity) else {
-                    continue;
-                };
-
-                let morph_deltas_buffer = model
-                    .morph_deltas_buffer
-                    .as_ref()
-                    .or(self.default_morph_deltas_buffer.as_ref());
-                let Some(morph_deltas_buffer) = morph_deltas_buffer else {
-                    log_once::error_once!("Missing morph deltas buffer for model {}", handle);
-                    continue;
-                };
-
-                let animation_bind_group = pipeline.animation_bind_group(
-                    graphics.clone(),
-                    default_skinning_buffer,
-                    morph_deltas_buffer,
-                    default_morph_weights_buffer,
-                    default_morph_info_buffer,
-                );
-
-                let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
-                    label: Some("model 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,
-                    }),
-                    occlusion_query_set: None,
-                    timestamp_writes: None,
-                    multiview_mask: None,
-                });
-
-                render_pass.set_pipeline(pipeline.pipeline());
-                let Some(instance_buffer) = self.instance_buffer_cache.get(&handle) else {
-                    continue;
-                };
-                render_pass.set_vertex_buffer(1, instance_buffer.slice(instance_count as usize));
-
-                for mesh in &model.meshes {
-                    let mut weights = mesh.morph_default_weights.clone();
-                    let target_count = mesh.morph_target_count as usize;
-                    if weights.len() < target_count {
-                        weights.resize(target_count, 0.0);
-                    }
-                    if weights.is_empty() {
-                        weights.push(0.0);
-                    }
-
-                    graphics.queue.write_buffer(
-                        default_morph_weights_buffer,
-                        0,
-                        bytemuck::cast_slice(&weights),
-                    );
-
-                    let info = MorphTargetInfo {
-                        num_vertices: mesh.morph_vertex_count,
-                        num_targets: mesh.morph_target_count,
-                        base_offset: mesh.morph_deltas_offset,
-                        weight_offset: 0,
-                        uses_morph: if mesh.morph_target_count > 0 && !weights.is_empty() {
-                            1
-                        } else {
-                            0
-                        },
-                        _padding: Default::default(),
-                    };
-
-                    let cache_key = mesh.morph_deltas_offset;
-                    let needs_write =
-                        self.last_morph_info_per_mesh
-                            .get(&cache_key)
-                            .map_or(true, |prev| {
-                                prev.num_vertices != info.num_vertices
-                                    || prev.num_targets != info.num_targets
-                                    || prev.base_offset != info.base_offset
-                                    || prev.uses_morph != info.uses_morph
-                            });
-
-                    if needs_write {
-                        graphics.queue.write_buffer(
-                            default_morph_info_buffer,
-                            0,
-                            bytemuck::bytes_of(&info),
-                        );
-                        self.last_morph_info_per_mesh.insert(cache_key, info);
-                    }
-
-                    let material = &model.materials[mesh.material];
-                    let material = if let Some(mat) = renderer.material_snapshot.get(&material.name)
-                    {
-                        mat
-                    } else {
-                        log_once::warn_once!(
-                            "Unable to locate MeshRenderer's material_snapshot for that specific material"
-                        );
-                        material
-                    };
-
-                    render_pass.draw_mesh_instanced(
-                        mesh,
-                        material,
-                        0..instance_count,
-                        &per_frame_bind_group,
-                        &animation_bind_group,
-                        environment_bind_group,
-                    );
-                }
-            }
-        }
-
-        // animated models
-        if let Some(_) = &self.light_cube_pipeline {
-            puffin::profile_scope!("animated model render pass");
-
-            let per_frame_bind_group = pipeline
-                .per_frame
-                .as_ref()
-                .expect("Per-frame bind group not initialised")
-                .clone();
-
-            for (entity, _, instance, _, _, _, _) in &self.animated_instances {
-                let instance_buffer = self
-                    .animated_instance_buffers
-                    .entry(*entity)
-                    .or_insert_with(|| {
-                        ResizableBuffer::new(
-                            &graphics.device,
-                            1,
-                            wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-                            "animated instance buffer",
-                        )
-                    });
-                instance_buffer.write(&graphics.device, &graphics.queue, &[*instance]);
-            }
-
-            for (
-                entity,
-                handle,
-                _,
-                skinning_buffer,
-                morph_weights_buffer,
-                morph_info_buffer,
-                morph_weight_count,
-            ) in &self.animated_instances
-            {
-                let Ok(renderer) = self.world.get::<&MeshRenderer>(*entity) else {
-                    continue;
-                };
-                puffin::profile_scope!("rendering animated model", format!("{:?}", entity));
-                {
-                    let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
-                        label: Some("animated model 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,
-                        }),
-                        occlusion_query_set: None,
-                        timestamp_writes: None,
-                        multiview_mask: None,
-                    });
-
-                    render_pass.set_pipeline(pipeline.pipeline());
-
-                    let Some(model) = model_cache.get(handle) else {
-                        log_once::error_once!("Missing model handle {} in registry", handle);
-                        continue;
-                    };
-
-                    let morph_deltas_buffer = model
-                        .morph_deltas_buffer
-                        .as_ref()
-                        .or(self.default_morph_deltas_buffer.as_ref());
-                    let Some(morph_deltas_buffer) = morph_deltas_buffer else {
-                        log_once::error_once!("Missing morph deltas buffer for model {}", handle);
-                        continue;
-                    };
-
-                    let mut hasher = DefaultHasher::new();
-                    skinning_buffer.hash(&mut hasher);
-                    let bind_group_stamp = hasher.finish();
-                    let animation_bind_group = {
-                        let cached = self.animated_bind_group_cache.get(entity);
-                        if cached.map_or(true, |(stamp, _)| *stamp != bind_group_stamp) {
-                            let bg = pipeline.animation_bind_group(
-                                graphics.clone(),
-                                skinning_buffer,
-                                morph_deltas_buffer,
-                                morph_weights_buffer,
-                                morph_info_buffer,
-                            );
-                            self.animated_bind_group_cache
-                                .insert(*entity, (bind_group_stamp, bg));
-                        }
-                        &self.animated_bind_group_cache[entity].1
-                    };
-
-                    let Some(instance_buffer) = self.animated_instance_buffers.get(entity) else {
-                        continue;
-                    };
-                    render_pass.set_vertex_buffer(1, instance_buffer.slice(1));
-
-                    for mesh in &model.meshes {
-                        let mesh_target_count = mesh.morph_target_count.min(*morph_weight_count);
-
-                        let info = MorphTargetInfo {
-                            num_vertices: mesh.morph_vertex_count,
-                            num_targets: mesh_target_count,
-                            base_offset: mesh.morph_deltas_offset,
-                            weight_offset: 0,
-                            uses_morph: if mesh_target_count > 0 { 1 } else { 0 },
-                            _padding: Default::default(),
-                        };
-
-                        graphics.queue.write_buffer(
-                            morph_info_buffer,
-                            0,
-                            bytemuck::bytes_of(&info),
-                        );
-
-                        let material = &model.materials[mesh.material];
-                        let material = if let Some(mat) =
-                            renderer.material_snapshot.get(&material.name)
-                        {
-                            mat
-                        } else {
-                            log_once::warn_once!(
-                                "Unable to locate MeshRenderer's material_snapshot for that specific material"
-                            );
-                            material
-                        };
-
-                        render_pass.draw_mesh_instanced(
-                            mesh,
-                            material,
-                            0..1,
-                            &per_frame_bind_group,
-                            animation_bind_group,
-                            environment_bind_group,
-                        );
-                    }
-                }
-            }
-        }
-
-        // 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 {
-                label: Some("sky 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,
-                multiview_mask: None,
-            });
-
-            render_pass.set_pipeline(&sky.pipeline);
-            render_pass.set_bind_group(0, &sky.camera_bind_group, &[]);
-            render_pass.set_bind_group(1, &sky.environment_bind_group, &[]);
-            render_pass.draw(0..3, 0..1);
-        }
-
-        // collider debug draw
-        {
-            let show_hitboxes = self
-                .current_scene_name
-                .as_ref()
-                .and_then(|scene_name| {
-                    let scenes = SCENES.read();
-                    scenes
-                        .iter()
-                        .find(|scene| &scene.scene_name == scene_name)
-                        .map(|scene| scene.settings.show_hitboxes)
-                })
-                .unwrap_or(false);
-
-            if show_hitboxes {
-                puffin::profile_scope!("collider debug draw");
-                if let Some(debug_draw) = graphics.debug_draw.lock().as_mut() {
-                    let colour = [0.0, 1.0, 0.0, 1.0]; // green
-                    let to_draw: Vec<_> = {
-                        let mut q = self.world.query::<(Entity, &ColliderGroup)>();
-                        q.iter().map(|(e, cg)| (e, cg.colliders.clone())).collect()
-                    };
-                    for (entity, colliders) in to_draw {
-                        let Ok(et) = self.world.get::<&EntityTransform>(entity) else {
-                            continue;
-                        };
-                        let world_tf = et.propagate(&self.world, entity);
-                        drop(et);
-                        for collider in &colliders {
-                            let entity_matrix = world_tf.matrix().as_mat4();
-                            let offset_transform = Transform::new()
-                                .with_offset(collider.translation, collider.rotation);
-                            let offset_matrix = offset_transform.matrix().as_mat4();
-                            let final_matrix = entity_matrix * offset_matrix;
-                            let (scale, rotation, translation) =
-                                final_matrix.to_scale_rotation_translation();
-
-                            debug_draw.draw_collider(&collider.shape, translation, scale, rotation, colour);
-                        }
-                    }
-                }
-            }
-        }
-
-        // kino billboard renderer
-        {
-            puffin::profile_scope!("rendering billboard targets");
-            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() {
-                    puffin::profile_scope!("rendering billboard", format!("{:?}", entity));
-                    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 Some(animation_defaults) = self.animation_pipeline.as_ref() else {
+            log_once::warn_once!("Animation pipeline not ready");
+            return;
+        };
+        let per_frame_bind_group = pipeline.per_frame.as_ref()
+            .expect("Per-frame bind group not initialised")
+            .clone();
+
+        RendererCommon::render_light_cubes(&graphics, &mut encoder, &hdr, &lights, &camera, self.light_cube_pipeline.as_ref());
+
+        RendererCommon::render_models(
+            &graphics, &mut encoder, &hdr,
+            &self.world, &batches, &model_cache,
+            &per_frame_bind_group, environment_bind_group,
+            pipeline, animation_defaults,
+            &self.instance_buffer_cache,
+            &mut self.animated_instance_buffers,
+            &mut self.animated_bind_group_cache,
+            &mut self.static_bind_group_cache,
+            &mut self.last_morph_info_per_mesh,
+        );
 
-                    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
-                        }
-                    };
+        RendererCommon::render_sky(&graphics, &mut encoder, &hdr, sky);
 
-                    let transform =
-                        Mat4::from_scale_rotation_translation(scale, rotation, position);
-                    billboards.push((transform, texture_view));
-                }
+        RendererCommon::render_collider_debug(
+            &graphics,
+            &self.world,
+            self.current_scene_name.as_deref(),
+        );
 
-                if !billboards.is_empty() {
-                    puffin::profile_scope!("billboard render pass");
-                    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,
-                        multiview_mask: None,
-                    });
-
-                    for (transform, texture_view) in billboards {
-                        billboard_pipeline.draw(
-                            graphics.clone(),
-                            &mut render_pass,
-                            transform,
-                            camera_projection,
-                            &texture_view,
-                        );
-                    }
-                }
-            }
-        }
+        RendererCommon::render_billboards(
+            &graphics, &mut encoder, &hdr, &camera,
+            &self.world,
+            self.kino.as_mut(),
+            self.billboard_pipeline.as_ref(),
+        );
 
-        // 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() { log_once::error_once!("{}", e); }
 
-        if let Err(e) = encoder.submit() {
-            log_once::error_once!("{}", e);
-        }
-
-        // kino hud renderer
-        if let Some(kino) = &mut self.kino {
+        {
+            let Some(kino) = &mut self.kino else { return };
             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);
             }
diff --git a/crates/redback-runtime/src/lib.rs b/crates/redback-runtime/src/lib.rs
index f3183de..75b11ac 100644
--- a/crates/redback-runtime/src/lib.rs
+++ b/crates/redback-runtime/src/lib.rs
@@ -9,6 +9,7 @@ use dropbear_engine::future::{FutureHandle, FutureQueue};
 use dropbear_engine::graphics::{InstanceRaw, SharedGraphicsContext};
 use dropbear_engine::pipelines::DropbearShaderPipeline;
 use dropbear_engine::pipelines::GlobalsUniform;
+use dropbear_engine::pipelines::animation::AnimationDefaults;
 use dropbear_engine::pipelines::light_cube::LightCubePipeline;
 use dropbear_engine::pipelines::shader::MainRenderPipeline;
 use dropbear_engine::scene::SceneCommand;
@@ -27,7 +28,6 @@ use eucalyptus_core::scene::loading::{SCENE_LOADER, SceneLoadResult};
 use eucalyptus_core::scripting::{ScriptManager, ScriptTarget};
 use eucalyptus_core::states::{SCENES, Script, WorldLoadingStatus};
 use futures::executor;
-use glam::Mat4;
 use hecs::{Entity, World};
 use kino_ui::KinoState;
 use kino_ui::rendering::KinoWGPURenderer;
@@ -37,15 +37,12 @@ use std::collections::HashMap;
 use std::path::PathBuf;
 use std::sync::Arc;
 use wgpu::SurfaceConfiguration;
-use wgpu::util::DeviceExt;
 use winit::window::Fullscreen;
 
 mod command;
 mod input;
 mod scene;
 
-const MAX_MORPH_WEIGHTS: usize = 4096;
-
 #[cfg(feature = "debug")]
 fn find_jvm_library_path() -> PathBuf {
     let proj = eucalyptus_core::states::PROJECT.read();
@@ -137,23 +134,10 @@ pub struct PlayMode {
     instance_buffer_cache: HashMap<u64, ResizableBuffer<InstanceRaw>>,
     animated_instance_buffers: HashMap<Entity, ResizableBuffer<InstanceRaw>>,
     sky_pipeline: Option<SkyPipeline>,
-    default_skinning_buffer: Option<wgpu::Buffer>,
-    default_morph_deltas_buffer: Option<wgpu::Buffer>,
-    default_morph_weights_buffer: Option<wgpu::Buffer>,
-    default_morph_info_buffer: Option<wgpu::Buffer>,
-    default_animation_bind_group: Option<wgpu::BindGroup>,
+    animation_pipeline: Option<AnimationDefaults>,
     billboard_pipeline: Option<BillboardPipeline>,
-    pub(crate) static_batches: HashMap<u64, Vec<(Entity, InstanceRaw)>>,
-    pub(crate) animated_instances: Vec<(
-        Entity,
-        u64,
-        InstanceRaw,
-        wgpu::Buffer,
-        wgpu::Buffer,
-        wgpu::Buffer,
-        u32,
-    )>,
     pub(crate) animated_bind_group_cache: HashMap<Entity, (u64, wgpu::BindGroup)>,
+    pub(crate) static_bind_group_cache: HashMap<u64, wgpu::BindGroup>,
     pub(crate) last_morph_info_per_mesh: HashMap<u32, MorphTargetInfo>,
 
     last_active_camera_for_per_frame: Option<Entity>,
@@ -238,15 +222,10 @@ impl PlayMode {
             },
             kino: None,
             sky_pipeline: None,
-            default_skinning_buffer: None,
-            default_morph_deltas_buffer: None,
-            default_morph_weights_buffer: None,
-            default_morph_info_buffer: None,
-            default_animation_bind_group: None,
+            animation_pipeline: None,
             billboard_pipeline: None,
-            static_batches: Default::default(),
-            animated_instances: vec![],
             animated_bind_group_cache: Default::default(),
+            static_bind_group_cache: Default::default(),
             last_morph_info_per_mesh: Default::default(),
             last_active_camera_for_per_frame: None,
         };
@@ -266,11 +245,8 @@ impl PlayMode {
         self.shader_globals = None;
         self.kino = None;
         self.sky_pipeline = None;
-        self.default_skinning_buffer = None;
-        self.default_morph_deltas_buffer = None;
-        self.default_morph_weights_buffer = None;
-        self.default_morph_info_buffer = None;
-        self.default_animation_bind_group = None;
+        self.animation_pipeline = None;
+        self.static_bind_group_cache.clear();
 
         self.load_wgpu_nerdy_stuff(graphics, sky_texture);
     }
@@ -289,95 +265,8 @@ impl PlayMode {
 
         let mut pending_sky_pipeline = None;
 
-        if self.default_skinning_buffer.is_none() {
-            let max_skinning_matrices = 256usize;
-            let identity = vec![Mat4::IDENTITY; max_skinning_matrices];
-            let skinning_buffer =
-                graphics
-                    .device
-                    .create_buffer_init(&wgpu::util::BufferInitDescriptor {
-                        label: Some("runtime default skinning buffer"),
-                        contents: bytemuck::cast_slice(&identity),
-                        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
-                    });
-
-            let morph_deltas_buffer =
-                graphics
-                    .device
-                    .create_buffer_init(&wgpu::util::BufferInitDescriptor {
-                        label: Some("runtime default morph deltas buffer"),
-                        contents: bytemuck::cast_slice(&[0.0f32]),
-                        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
-                    });
-
-            let morph_weights = vec![0.0f32; MAX_MORPH_WEIGHTS];
-            let morph_weights_buffer =
-                graphics
-                    .device
-                    .create_buffer_init(&wgpu::util::BufferInitDescriptor {
-                        label: Some("runtime default morph weights buffer"),
-                        contents: bytemuck::cast_slice(&morph_weights),
-                        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
-                    });
-
-            let morph_info = dropbear_engine::animation::MorphTargetInfo::default();
-            let morph_info_buffer =
-                graphics
-                    .device
-                    .create_buffer_init(&wgpu::util::BufferInitDescriptor {
-                        label: Some("runtime default morph info buffer"),
-                        contents: bytemuck::bytes_of(&morph_info),
-                        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
-                    });
-
-            self.default_skinning_buffer = Some(skinning_buffer);
-            self.default_morph_deltas_buffer = Some(morph_deltas_buffer);
-            self.default_morph_weights_buffer = Some(morph_weights_buffer);
-            self.default_morph_info_buffer = Some(morph_info_buffer);
-        }
-
-        if self.default_animation_bind_group.is_none() {
-            let skinning_buffer = self
-                .default_skinning_buffer
-                .as_ref()
-                .expect("Default skinning buffer missing");
-            let morph_deltas_buffer = self
-                .default_morph_deltas_buffer
-                .as_ref()
-                .expect("Default morph deltas buffer missing");
-            let morph_weights_buffer = self
-                .default_morph_weights_buffer
-                .as_ref()
-                .expect("Default morph weights buffer missing");
-            let morph_info_buffer = self
-                .default_morph_info_buffer
-                .as_ref()
-                .expect("Default morph info buffer missing");
-
-            self.default_animation_bind_group = Some(graphics.device.create_bind_group(
-                &wgpu::BindGroupDescriptor {
-                    label: Some("runtime default animation bind group"),
-                    layout: &graphics.layouts.animation_layout,
-                    entries: &[
-                        wgpu::BindGroupEntry {
-                            binding: 0,
-                            resource: skinning_buffer.as_entire_binding(),
-                        },
-                        wgpu::BindGroupEntry {
-                            binding: 1,
-                            resource: morph_deltas_buffer.as_entire_binding(),
-                        },
-                        wgpu::BindGroupEntry {
-                            binding: 2,
-                            resource: morph_weights_buffer.as_entire_binding(),
-                        },
-                        wgpu::BindGroupEntry {
-                            binding: 3,
-                            resource: morph_info_buffer.as_entire_binding(),
-                        },
-                    ],
-                },
-            ));
+        if self.animation_pipeline.is_none() {
+            self.animation_pipeline = Some(AnimationDefaults::new(graphics.clone()));
         }
 
         self.kino = Some(kino_ui::KinoState::new(
diff --git a/crates/redback-runtime/src/scene.rs b/crates/redback-runtime/src/scene.rs
index 2cb8e39..61ec844 100644
--- a/crates/redback-runtime/src/scene.rs
+++ b/crates/redback-runtime/src/scene.rs
@@ -1,36 +1,29 @@
 use std::sync::Arc;
 
 use crate::PlayMode;
-use dropbear_engine::animation::{AnimationComponent, MorphTargetInfo};
-use dropbear_engine::asset::{ASSET_REGISTRY, Handle};
-use dropbear_engine::buffer::ResizableBuffer;
 use dropbear_engine::camera::Camera;
-use dropbear_engine::entity::{EntityTransform, MeshRenderer, Transform};
+use dropbear_engine::entity::EntityTransform;
 use dropbear_engine::graphics::CommandEncoder;
-use dropbear_engine::graphics::{InstanceRaw, SharedGraphicsContext};
-use dropbear_engine::lighting::Light;
-use dropbear_engine::model::{DrawLight, DrawModel};
-use dropbear_engine::pipelines::DropbearShaderPipeline;
+use dropbear_engine::graphics::SharedGraphicsContext;
 use dropbear_engine::scene::{Scene, SceneCommand};
 use eucalyptus_core::billboard::BillboardComponent;
 use eucalyptus_core::command::CommandBufferPoller;
 use eucalyptus_core::egui::CentralPanel;
 use eucalyptus_core::entity_status::EntityStatus;
 use eucalyptus_core::hierarchy::{EntityTransformExt, Parent};
-use eucalyptus_core::physics::collider::{ColliderGroup, ColliderShape};
 use eucalyptus_core::physics::kcc::KCC;
 use eucalyptus_core::rapier3d::geometry::SharedShape;
 use eucalyptus_core::rapier3d::prelude::QueryFilter;
+use eucalyptus_core::rendering::RendererCommon;
 use eucalyptus_core::scene::loading::{IsSceneLoaded, SCENE_LOADER, SceneLoadResult};
 use eucalyptus_core::states::SCENES;
 use eucalyptus_core::states::{Label, PROJECT};
 use eucalyptus_core::ui::HUDComponent;
-use glam::{DVec3, Mat4, Quat, Vec2, Vec3};
+use glam::{DVec3, Mat4, Quat, Vec2};
 use hecs::Entity;
 use kino_ui::WidgetTree;
 use kino_ui::rendering::KinoRenderTargetId;
 use std::collections::HashMap;
-use std::hash::{DefaultHasher, Hash, Hasher};
 use winit::event::WindowEvent;
 use winit::event_loop::ActiveEventLoop;
 
@@ -608,80 +601,21 @@ impl Scene for PlayMode {
 
     fn render<'a>(&mut self, graphics: Arc<SharedGraphicsContext>) {
         let hdr = graphics.hdr.read();
-
         let mut encoder = CommandEncoder::new(graphics.clone(), Some("runtime viewport encoder"));
 
-        let active_camera = { self.active_camera.as_ref().cloned() };
-        let Some(active_camera) = active_camera else {
-            return;
-        };
+        let Some(active_camera) = self.active_camera.as_ref().cloned() else { return };
         log_once::debug_once!("Active camera found: {:?}", active_camera);
+        let Some(camera) = self.world.query_one::<&Camera>(active_camera).get().ok().cloned() else { return };
+        log_once::debug_once!("Camera ready: {}", camera.label);
 
-        let q = self
-            .world
-            .query_one::<&Camera>(active_camera)
-            .get()
-            .ok()
-            .cloned();
-
-        let Some(camera) = q else {
-            return;
-        };
-        log_once::debug_once!("Camera ready");
-        log_once::debug_once!("Camera currently being viewed: {}", camera.label);
-
-        // clear viewport render pass
-        {
-            puffin::profile_scope!("Clearing viewport");
-            let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
-                label: Some("viewport clear pass"),
-                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
-                    view: hdr.render_view(),
-                    depth_slice: None,
-                    resolve_target: hdr.resolve_target(),
-                    ops: wgpu::Operations {
-                        load: wgpu::LoadOp::Clear(wgpu::Color {
-                            r: 100.0 / 255.0,
-                            g: 149.0 / 255.0,
-                            b: 237.0 / 255.0,
-                            a: 1.0,
-                        }),
-                        store: wgpu::StoreOp::Store,
-                    },
-                })],
-                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
-                    view: &graphics.depth_texture.view,
-                    depth_ops: Some(wgpu::Operations {
-                        load: wgpu::LoadOp::Clear(0.0),
-                        store: wgpu::StoreOp::Store,
-                    }),
-                    stencil_ops: None,
-                }),
-                occlusion_query_set: None,
-                timestamp_writes: None,
-                multiview_mask: None,
-            });
-        }
+        RendererCommon::clear_viewport(&graphics, &mut encoder, &hdr);
 
         if let Some(light_pipeline) = &mut self.light_cube_pipeline {
             light_pipeline.update(graphics.clone(), &self.world);
         }
-
-        let (lights, enabled_light_count) = {
-            puffin::profile_scope!("Locating lights");
-            let mut lights = Vec::new();
-            let mut enabled = 0u32;
-            for light in self.world.query::<&Light>().iter() {
-                if light.component.enabled {
-                    enabled += 1;
-                }
-                lights.push(light.clone());
-            }
-            (lights, enabled)
-        };
+        let (lights, enabled_light_count) = RendererCommon::collect_lights(&self.world);
 
         if let Some(globals) = &mut self.shader_globals {
-            puffin::profile_scope!("Fetching globals");
             globals.set_num_lights(enabled_light_count);
             if let Some(scene_name) = &self.current_scene {
                 let scenes = SCENES.read();
@@ -692,197 +626,11 @@ impl Scene for PlayMode {
             globals.write(&graphics.queue);
         }
 
-        self.static_batches.clear();
-        self.animated_instances.clear();
-        {
-            puffin::profile_scope!("finding all renderers and animation components");
-            let mut query = self
-                .world
-                .query::<(Entity, &MeshRenderer, Option<&mut AnimationComponent>)>();
-
-            for (entity, renderer, animation) in query.iter() {
-                puffin::profile_scope!(format!("locating {:?}", entity));
-                // skip entities that are hidden or disabled
-                let world_ptr = &*self.world as *const hecs::World;
-                let world_ref = unsafe { &*world_ptr };
-                if let Ok(status) = world_ref.get::<&EntityStatus>(entity) {
-                    if status.hidden || status.disabled {
-                        continue;
-                    }
-                }
-                let handle = renderer.model();
-                if handle.is_null() {
-                    continue;
-                }
-
-                let instance = renderer.instance.to_raw();
-
-                if let Some(animation) = animation {
-                    let has_skinning = !animation.skinning_matrices.is_empty();
-                    let has_morph = !animation.morph_weights.is_empty();
-
-                    if !has_skinning && !has_morph {
-                        self.static_batches
-                            .entry(handle.id)
-                            .or_default()
-                            .push((entity, instance));
-                        continue;
-                    }
-
-                    animation.prepare_gpu_resources(graphics.clone());
-
-                    let skinning_buffer = match animation
-                        .skinning_buffer
-                        .as_ref()
-                        .map(|b| b.buffer().clone())
-                    {
-                        Some(buf) => buf,
-                        None if !has_skinning => {
-                            let Some(default) = self.default_skinning_buffer.as_ref() else {
-                                self.static_batches
-                                    .entry(handle.id)
-                                    .or_default()
-                                    .push((entity, instance));
-                                continue;
-                            };
-                            default.clone()
-                        }
-                        None => {
-                            self.static_batches
-                                .entry(handle.id)
-                                .or_default()
-                                .push((entity, instance));
-                            continue;
-                        }
-                    };
-
-                    let Some(morph_weights_buffer) = animation
-                        .morph_weights_buffer
-                        .as_ref()
-                        .map(|b| b.buffer().clone())
-                    else {
-                        self.static_batches
-                            .entry(handle.id)
-                            .or_default()
-                            .push((entity, instance));
-                        continue;
-                    };
-
-                    let Some(morph_info_buffer) = animation
-                        .morph_info_buffer
-                        .as_ref()
-                        .map(|b| b.buffer().clone())
-                    else {
-                        self.static_batches
-                            .entry(handle.id)
-                            .or_default()
-                            .push((entity, instance));
-                        continue;
-                    };
-
-                    self.animated_instances.push((
-                        entity,
-                        handle.id,
-                        instance,
-                        skinning_buffer,
-                        morph_weights_buffer,
-                        morph_info_buffer,
-                        animation.morph_weight_count,
-                    ));
-                } else {
-                    self.static_batches
-                        .entry(handle.id)
-                        .or_default()
-                        .push((entity, instance));
-                }
-            }
-        }
-
-        let registry = ASSET_REGISTRY.read();
+        let mut batches = HashMap::new();
+        let default_skinning = self.animation_pipeline.as_ref().map(|p| p.skinning_buffer.buffer().clone());
+        RendererCommon::locate_renderers(&self.world, &mut batches, graphics.clone(), &default_skinning);
 
-        let mut model_cache: HashMap<u64, _> = HashMap::new();
-        let mut prepared_models = Vec::new();
-        for (handle, batched_instances) in &self.static_batches {
-            puffin::profile_scope!("preparing models");
-            let Some(model) = registry.get_model(Handle::new(*handle)) else {
-                log_once::error_once!("Missing model handle {} in registry", handle);
-                continue;
-            };
-
-            let entity = batched_instances.first().map(|(e, _)| *e);
-            let instances: Vec<InstanceRaw> =
-                batched_instances.iter().map(|(_, inst)| *inst).collect();
-
-            let instance_buffer = self
-                .instance_buffer_cache
-                .entry(*handle)
-                .or_insert_with(|| {
-                    ResizableBuffer::new(
-                        &graphics.device,
-                        instances.len().max(1),
-                        wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-                        "Runtime Instance Buffer",
-                    )
-                });
-            instance_buffer.write(&graphics.device, &graphics.queue, &instances);
-
-            model_cache.insert(*handle, model.clone());
-            prepared_models.push((model, *handle, instances.len() as u32, entity));
-        }
-
-        for (_, handle, ..) in &self.animated_instances {
-            if !model_cache.contains_key(handle) {
-                if let Some(model) = registry.get_model(Handle::new(*handle)) {
-                    model_cache.insert(*handle, model);
-                }
-            }
-        }
-
-        // light cube rendering
-        if let Some(light_pipeline) = &self.light_cube_pipeline {
-            if let Some(l) = lights.first()
-                && let Some(model) = registry.get_model(l.cube_model)
-            {
-                {
-                    puffin::profile_scope!("light cube pass");
-                    let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
-                        label: Some("light cube 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,
-                        }),
-                        occlusion_query_set: None,
-                        timestamp_writes: None,
-                        multiview_mask: None,
-                    });
-
-                    render_pass.set_pipeline(light_pipeline.pipeline());
-                    for light in &lights {
-                        puffin::profile_scope!("rendering light", &light.label);
-                        render_pass.set_vertex_buffer(1, light.instance_buffer.buffer().slice(..));
-                        if !light.component.visible {
-                            continue;
-                        }
-                        render_pass.draw_light_model(&model, &camera.bind_group, &light.bind_group);
-                    }
-                }
-            } else {
-                log_once::error_once!("Missing light cube model handle in registry",);
-            }
-        }
+        let (_, model_cache) = RendererCommon::prepare_models(&graphics, &batches, &mut self.instance_buffer_cache);
 
         if self.last_active_camera_for_per_frame != Some(active_camera) {
             self.last_active_camera_for_per_frame = Some(active_camera);
@@ -901,581 +649,61 @@ impl Scene for PlayMode {
             }
         }
 
-        let sky = self
-            .sky_pipeline
-            .as_ref()
-            .expect("Sky pipeline must be initialised before rendering models");
+        let sky = self.sky_pipeline.as_ref().expect("Sky pipeline must be initialised before rendering models");
         let environment_bind_group = &sky.environment_bind_group;
 
-        let Some(pipeline) = self.main_pipeline.as_mut() else {
+        let Some(pipeline) = self.main_pipeline.as_ref() else {
             log_once::warn_once!("Render pipeline not ready");
             return;
         };
-        log_once::debug_once!("Pipeline ready");
-
-        // static models
-        if let Some(_) = &self.light_cube_pipeline {
-            puffin::profile_scope!("model render pass");
-
-            let default_skinning_buffer = self
-                .default_skinning_buffer
-                .as_ref()
-                .expect("Default skinning buffer not initialised");
-            let default_morph_weights_buffer = self
-                .default_morph_weights_buffer
-                .as_ref()
-                .expect("Default morph weights buffer not initialised");
-            let default_morph_info_buffer = self
-                .default_morph_info_buffer
-                .as_ref()
-                .expect("Default morph info buffer not initialised");
-            let per_frame_bind_group = pipeline
-                .per_frame
-                .as_ref()
-                .expect("Per-frame bind group not initialised")
-                .clone();
-
-            for (model, handle, instance_count, entity) in prepared_models {
-                let Some(entity) = entity else { continue };
-                let Ok(renderer) = self.world.get::<&MeshRenderer>(entity) else {
-                    continue;
-                };
-
-                let morph_deltas_buffer = model
-                    .morph_deltas_buffer
-                    .as_ref()
-                    .or(self.default_morph_deltas_buffer.as_ref());
-                let Some(morph_deltas_buffer) = morph_deltas_buffer else {
-                    log_once::error_once!("Missing morph deltas buffer for model {}", handle);
-                    continue;
-                };
-
-                let animation_bind_group = pipeline.animation_bind_group(
-                    graphics.clone(),
-                    default_skinning_buffer,
-                    morph_deltas_buffer,
-                    default_morph_weights_buffer,
-                    default_morph_info_buffer,
-                );
-
-                let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
-                    label: Some("model 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,
-                    }),
-                    occlusion_query_set: None,
-                    timestamp_writes: None,
-                    multiview_mask: None,
-                });
-
-                render_pass.set_pipeline(pipeline.pipeline());
-                let Some(instance_buffer) = self.instance_buffer_cache.get(&handle) else {
-                    continue;
-                };
-                render_pass.set_vertex_buffer(1, instance_buffer.slice(instance_count as usize));
-
-                for mesh in &model.meshes {
-                    let mut weights = mesh.morph_default_weights.clone();
-                    let target_count = mesh.morph_target_count as usize;
-                    if weights.len() < target_count {
-                        weights.resize(target_count, 0.0);
-                    }
-                    if weights.is_empty() {
-                        weights.push(0.0);
-                    }
-
-                    graphics.queue.write_buffer(
-                        default_morph_weights_buffer,
-                        0,
-                        bytemuck::cast_slice(&weights),
-                    );
-
-                    let info = MorphTargetInfo {
-                        num_vertices: mesh.morph_vertex_count,
-                        num_targets: mesh.morph_target_count,
-                        base_offset: mesh.morph_deltas_offset,
-                        weight_offset: 0,
-                        uses_morph: if mesh.morph_target_count > 0 && !weights.is_empty() {
-                            1
-                        } else {
-                            0
-                        },
-                        _padding: Default::default(),
-                    };
-
-                    let cache_key = mesh.morph_deltas_offset;
-                    let needs_write =
-                        self.last_morph_info_per_mesh
-                            .get(&cache_key)
-                            .map_or(true, |prev| {
-                                prev.num_vertices != info.num_vertices
-                                    || prev.num_targets != info.num_targets
-                                    || prev.base_offset != info.base_offset
-                                    || prev.uses_morph != info.uses_morph
-                            });
-
-                    if needs_write {
-                        graphics.queue.write_buffer(
-                            default_morph_info_buffer,
-                            0,
-                            bytemuck::bytes_of(&info),
-                        );
-                        self.last_morph_info_per_mesh.insert(cache_key, info);
-                    }
-
-                    let material = &model.materials[mesh.material];
-                    let material = if let Some(mat) = renderer.material_snapshot.get(&material.name)
-                    {
-                        mat
-                    } else {
-                        log_once::warn_once!(
-                            "Unable to locate MeshRenderer's material_snapshot for that specific material"
-                        );
-                        material
-                    };
-
-                    render_pass.draw_mesh_instanced(
-                        mesh,
-                        material,
-                        0..instance_count,
-                        &per_frame_bind_group,
-                        &animation_bind_group,
-                        environment_bind_group,
-                    );
-                }
-            }
-        }
-
-        // animated models
-        if let Some(_) = &self.light_cube_pipeline {
-            puffin::profile_scope!("animated model render pass");
-
-            let per_frame_bind_group = pipeline
-                .per_frame
-                .as_ref()
-                .expect("Per-frame bind group not initialised")
-                .clone();
-
-            for (entity, _, instance, _, _, _, _) in &self.animated_instances {
-                let instance_buffer = self
-                    .animated_instance_buffers
-                    .entry(*entity)
-                    .or_insert_with(|| {
-                        ResizableBuffer::new(
-                            &graphics.device,
-                            1,
-                            wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-                            "animated instance buffer",
-                        )
-                    });
-                instance_buffer.write(&graphics.device, &graphics.queue, &[*instance]);
-            }
-
-            for (
-                entity,
-                handle,
-                _,
-                skinning_buffer,
-                morph_weights_buffer,
-                morph_info_buffer,
-                morph_weight_count,
-            ) in &self.animated_instances
-            {
-                let Ok(renderer) = self.world.get::<&MeshRenderer>(*entity) else {
-                    continue;
-                };
-                puffin::profile_scope!("rendering animated model", format!("{:?}", entity));
-                {
-                    let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
-                        label: Some("animated model 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,
-                        }),
-                        occlusion_query_set: None,
-                        timestamp_writes: None,
-                        multiview_mask: None,
-                    });
-
-                    render_pass.set_pipeline(pipeline.pipeline());
-
-                    let Some(model) = model_cache.get(handle) else {
-                        log_once::error_once!("Missing model handle {} in registry", handle);
-                        continue;
-                    };
-
-                    let morph_deltas_buffer = model
-                        .morph_deltas_buffer
-                        .as_ref()
-                        .or(self.default_morph_deltas_buffer.as_ref());
-                    let Some(morph_deltas_buffer) = morph_deltas_buffer else {
-                        log_once::error_once!("Missing morph deltas buffer for model {}", handle);
-                        continue;
-                    };
-
-                    let mut hasher = DefaultHasher::new();
-                    skinning_buffer.hash(&mut hasher);
-                    let bind_group_stamp = hasher.finish();
-                    let animation_bind_group = {
-                        let cached = self.animated_bind_group_cache.get(entity);
-                        if cached.map_or(true, |(stamp, _)| *stamp != bind_group_stamp) {
-                            let bg = pipeline.animation_bind_group(
-                                graphics.clone(),
-                                skinning_buffer,
-                                morph_deltas_buffer,
-                                morph_weights_buffer,
-                                morph_info_buffer,
-                            );
-                            self.animated_bind_group_cache
-                                .insert(*entity, (bind_group_stamp, bg));
-                        }
-                        &self.animated_bind_group_cache[entity].1
-                    };
-
-                    let Some(instance_buffer) = self.animated_instance_buffers.get(entity) else {
-                        continue;
-                    };
-                    render_pass.set_vertex_buffer(1, instance_buffer.slice(1));
-
-                    for mesh in &model.meshes {
-                        let mesh_target_count = mesh.morph_target_count.min(*morph_weight_count);
-
-                        let info = MorphTargetInfo {
-                            num_vertices: mesh.morph_vertex_count,
-                            num_targets: mesh_target_count,
-                            base_offset: mesh.morph_deltas_offset,
-                            weight_offset: 0,
-                            uses_morph: if mesh_target_count > 0 { 1 } else { 0 },
-                            _padding: Default::default(),
-                        };
-
-                        graphics.queue.write_buffer(
-                            morph_info_buffer,
-                            0,
-                            bytemuck::bytes_of(&info),
-                        );
-
-                        let material = &model.materials[mesh.material];
-                        let material = if let Some(mat) =
-                            renderer.material_snapshot.get(&material.name)
-                        {
-                            mat
-                        } else {
-                            log_once::warn_once!(
-                                "Unable to locate MeshRenderer's material_snapshot for that specific material"
-                            );
-                            material
-                        };
-
-                        render_pass.draw_mesh_instanced(
-                            mesh,
-                            material,
-                            0..1,
-                            &per_frame_bind_group,
-                            animation_bind_group,
-                            environment_bind_group,
-                        );
-                    }
-                }
-            }
-        }
-
-        // 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 {
-                label: Some("sky 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,
-                multiview_mask: None,
-            });
-
-            render_pass.set_pipeline(&sky.pipeline);
-            render_pass.set_bind_group(0, &sky.camera_bind_group, &[]);
-            render_pass.set_bind_group(1, &sky.environment_bind_group, &[]);
-            render_pass.draw(0..3, 0..1);
-        }
-
-        // collider debug draw
-        {
-            let show_hitboxes = self
-                .current_scene
-                .as_ref()
-                .and_then(|scene_name| {
-                    let scenes = SCENES.read();
-                    scenes
-                        .iter()
-                        .find(|scene| &scene.scene_name == scene_name)
-                        .map(|scene| scene.settings.show_hitboxes)
-                })
-                .unwrap_or(false);
-
-            if show_hitboxes {
-                puffin::profile_scope!("collider debug draw");
-                if let Some(debug_draw) = graphics.debug_draw.lock().as_mut() {
-                    let colour = [0.0, 1.0, 0.0, 1.0];
-                    let to_draw: Vec<_> = {
-                        let mut q = self.world.query::<(Entity, &ColliderGroup)>();
-                        q.iter().map(|(e, cg)| (e, cg.colliders.clone())).collect()
-                    };
-                    for (entity, colliders) in to_draw {
-                        let Ok(et) = self.world.get::<&EntityTransform>(entity) else {
-                            continue;
-                        };
-                        let world_tf = et.propagate(&self.world, entity);
-                        drop(et);
-                        for collider in &colliders {
-                            let entity_matrix = world_tf.matrix().as_mat4();
-                            let offset_transform = Transform::new()
-                                .with_offset(collider.translation, collider.rotation);
-                            let offset_matrix = offset_transform.matrix().as_mat4();
-                            let final_matrix = entity_matrix * offset_matrix;
-                            let (scale, rotation, translation) =
-                                final_matrix.to_scale_rotation_translation();
-
-                            match &collider.shape {
-                                ColliderShape::Box { half_extents } => {
-                                    let he = Vec3::new(
-                                        half_extents.x as f32 * scale.x,
-                                        half_extents.y as f32 * scale.y,
-                                        half_extents.z as f32 * scale.z,
-                                    );
-                                    debug_draw.draw_obb(translation, he, rotation, colour);
-                                }
-                                ColliderShape::Sphere { radius } => {
-                                    let r = radius * scale.x.max(scale.y).max(scale.z);
-                                    debug_draw.draw_sphere(translation, r, colour);
-                                }
-                                ColliderShape::Capsule {
-                                    half_height,
-                                    radius,
-                                } => {
-                                    let axis = rotation * Vec3::Y;
-                                    let top = translation + axis * (half_height * scale.y);
-                                    let bottom = translation - axis * (half_height * scale.y);
-                                    debug_draw.draw_capsule(
-                                        bottom,
-                                        top,
-                                        radius * scale.x.max(scale.z),
-                                        colour,
-                                    );
-                                }
-                                ColliderShape::Cylinder {
-                                    half_height,
-                                    radius,
-                                } => {
-                                    let axis = rotation * Vec3::Y;
-                                    debug_draw.draw_cylinder(
-                                        translation,
-                                        half_height * scale.y,
-                                        radius * scale.x.max(scale.z),
-                                        axis,
-                                        colour,
-                                    );
-                                }
-                                ColliderShape::Cone {
-                                    half_height,
-                                    radius,
-                                } => {
-                                    let axis = rotation * Vec3::Y;
-                                    let apex = translation + axis * (half_height * scale.y);
-                                    let height = 2.0 * half_height * scale.y;
-                                    let r = radius * scale.x.max(scale.z);
-                                    debug_draw.draw_cone(
-                                        apex,
-                                        -(rotation * Vec3::Y),
-                                        (r / height).atan(),
-                                        height,
-                                        colour,
-                                    );
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        }
-
-        // kino billboard renderer
-        {
-            puffin::profile_scope!("rendering billboard targets");
-            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() {
-                    puffin::profile_scope!("rendering billboard", format!("{:?}", entity));
-                    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 Some(animation_defaults) = self.animation_pipeline.as_ref() else {
+            log_once::warn_once!("Animation pipeline not ready");
+            return;
+        };
+        let per_frame_bind_group = pipeline.per_frame.as_ref()
+            .expect("Per-frame bind group not initialised")
+            .clone();
+
+        RendererCommon::render_light_cubes(&graphics, &mut encoder, &hdr, &lights, &camera, self.light_cube_pipeline.as_ref());
+
+        RendererCommon::render_models(
+            &graphics, &mut encoder, &hdr,
+            &self.world, &batches, &model_cache,
+            &per_frame_bind_group, environment_bind_group,
+            pipeline, animation_defaults,
+            &self.instance_buffer_cache,
+            &mut self.animated_instance_buffers,
+            &mut self.animated_bind_group_cache,
+            &mut self.static_bind_group_cache,
+            &mut self.last_morph_info_per_mesh,
+        );
 
-                    let transform =
-                        Mat4::from_scale_rotation_translation(scale, rotation, position);
-                    billboards.push((transform, texture_view));
-                }
+        RendererCommon::render_sky(&graphics, &mut encoder, &hdr, sky);
 
-                if !billboards.is_empty() {
-                    puffin::profile_scope!("billboard render pass");
-                    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,
-                        multiview_mask: None,
-                    });
+        RendererCommon::render_collider_debug(
+            &graphics,
+            &self.world,
+            self.current_scene.as_deref(),
+        );
 
-                    for (transform, texture_view) in billboards {
-                        billboard_pipeline.draw(
-                            graphics.clone(),
-                            &mut render_pass,
-                            transform,
-                            camera_projection,
-                            &texture_view,
-                        );
-                    }
-                }
-            }
-        }
+        RendererCommon::render_billboards(
+            &graphics, &mut encoder, &hdr, &camera,
+            &self.world,
+            self.kino.as_mut(),
+            self.billboard_pipeline.as_ref(),
+        );
 
-        // 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() { log_once::error_once!("{}", e); }
 
-        if let Err(e) = encoder.submit() {
-            log_once::error_once!("{}", e);
-        }
-
-        // kino hud renderer
         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);
             }