kitgit

tirbofish/dropbear · commit

9d4eb6d1b7b0058e5ec70c50e198b7c88da70874

feature: pbr (i hope)

Unverified

Thribhu K <4tkbytes@pm.me> · 2026-03-24 11:48

view full diff

diff --git a/Cargo.toml b/Cargo.toml
index 5e4273c..afea4c0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -31,7 +31,7 @@ env_logger = "0.11"
 futures = "0.3"
 gilrs = "0.11"
 git2 = { version = "0.20", features = ["vendored-openssl"] }
-glam = { version = "0.30", features = ["serde", "mint", "bytemuck", "rkyv", "bytecheck"] }
+glam = { version = "0.30", features = ["serde", "mint", "bytemuck", "rkyv", "bytecheck"] } # required to be at 0.30 because of rapier3d not being updated yet
 hecs = { version = "0.11", features = ["serde"] }
 log = "0.4"
 log-once = "0.4"
@@ -46,7 +46,7 @@ transform-gizmo-egui = { version = "0.8" }
 tokio = { version = "1", features = ["full"] }
 wgpu = "27"
 winit = { version = "0.30", features = [] }
-zip = "7.0"
+zip = "8.4"
 walkdir = "2.5"
 rayon = "1.11"
 backtrace = "0.3"
@@ -79,15 +79,16 @@ thiserror = "2.0"
 tempfile = "3.24"
 combine = "4.6"
 glyphon = { git = "https://github.com/grovesNL/glyphon", rev = "9dd9376" }
-puffin = "0.19"
+puffin = "0.20"
 bitflags = "2.10"
 features = "0.10"
-puffin_http = "0.16"
+puffin_http = "0.17"
 dyn-clone = "1.0"
 downcast-rs = "2.0"
 float-derive = "0.1"
 rkyv = "0.8"
 bevy_mikktspace = "1.0.0"
+naga = { version = "29", features = ["wgsl-in"] }
 
 [workspace.dependencies.image]
 version = "0.25"
diff --git a/crates/dropbear-engine/Cargo.toml b/crates/dropbear-engine/Cargo.toml
index 4421245..7fab0c5 100644
--- a/crates/dropbear-engine/Cargo.toml
+++ b/crates/dropbear-engine/Cargo.toml
@@ -57,3 +57,5 @@ rfd.workspace = true
 
 [build-dependencies]
 slank = { path = "../slank", features = ["download-slang"] }
+naga.workspace = true
+anyhow.workspace = true
diff --git a/crates/dropbear-engine/build.rs b/crates/dropbear-engine/build.rs
index 1df492c..383ef4b 100644
--- a/crates/dropbear-engine/build.rs
+++ b/crates/dropbear-engine/build.rs
@@ -1,20 +1,51 @@
+use std::path::Path;
 use slank::{SlangShaderBuilder, SlangTarget};
 
-fn main() {
+fn main() -> anyhow::Result<()> {
     // to copy paste:
     // let shader = Shader::from_slang(graphics.clone(), &slank::compiled::CompiledSlangShader::from_bytes("light cube", include_slang!("light_cube")));
 
     SlangShaderBuilder::new("light_cube")
-        .add_source_path("src/shaders/light.slang")
-        .unwrap()
-        .compile_to_out_dir(SlangTarget::SpirV)
-        .unwrap();
+        .add_source_path("src/shaders/light.slang")?
+        .compile_to_out_dir(SlangTarget::SpirV)?;
 
     SlangShaderBuilder::new("blit_shader")
-        .add_source_path("src/shaders/blit.slang")
-        .unwrap()
-        .compile_to_out_dir(SlangTarget::SpirV)
-        .unwrap();
+        .add_source_path("src/shaders/blit.slang")?
+        .compile_to_out_dir(SlangTarget::SpirV)?;
 
     println!("cargo:rerun-if-changed=src/shaders");
+    
+    validate_wgsl()?;
+
+    Ok(())
+}
+
+fn validate_wgsl() -> anyhow::Result<()> {
+    let shader_dir = Path::new("src/shaders");
+
+    for entry in std::fs::read_dir(shader_dir)? {
+        let path = entry?.path();
+        if path.extension().and_then(|e| e.to_str()) != Some("wgsl") {
+            continue;
+        }
+
+        println!("cargo:rerun-if-changed={}", path.display());
+
+        let src = std::fs::read_to_string(&path)?;
+
+        let mut frontend = naga::front::wgsl::Frontend::new();
+        let module = frontend.parse(&src).unwrap_or_else(|e| {
+            panic!("WGSL parse error in {}:\n{}", path.display(), e.emit_to_string(&src));
+        });
+
+        let mut validator = naga::valid::Validator::new(
+            naga::valid::ValidationFlags::all(),
+            naga::valid::Capabilities::all(),
+        );
+        validator.validate(&module).unwrap_or_else(|e| {
+            panic!("WGSL validation error in {}:\n{:?}", path.display(), e);
+        });
+    }
+
+    Ok(())
 }
diff --git a/crates/dropbear-engine/src/asset.rs b/crates/dropbear-engine/src/asset.rs
index e524609..7d4b0e2 100644
--- a/crates/dropbear-engine/src/asset.rs
+++ b/crates/dropbear-engine/src/asset.rs
@@ -3,7 +3,7 @@ use crate::model::Model;
 use crate::texture::{Texture, TextureBuilder};
 use crate::utils::ResourceReference;
 use parking_lot::RwLock;
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
 use std::hash::{DefaultHasher, Hash, Hasher};
 use std::marker::PhantomData;
 use std::sync::{Arc, LazyLock};
@@ -128,39 +128,59 @@ impl AssetRegistry {
     }
 
     /// Flushes away all unused assets and returns the count of flushed models.
-    ///
-    /// Can be used to reduce the memory being used however, to reuse them, you will need to reimport
-    /// them into memory.
-    pub fn flush_unused(&mut self) -> usize {
+    pub fn flush_unused_with_live_ids(&mut self, live_model_ids: &HashSet<u64>) -> usize {
+        log::debug!("Flushing unused assets");
+        let mut live_texture_ids: HashSet<u64> = HashSet::new();
         let mut counter = 0;
 
         // models
         self.models.retain(|id, arc| {
             let is_null = *id == 0;
             let is_protected = arc.label.eq_ignore_ascii_case("light cube");
-            
-            if is_null || is_protected {
+            let is_live = live_model_ids.contains(id);
+            let has_external_arc = Arc::get_mut(arc).is_none();
+
+            if is_null || is_protected || is_live || has_external_arc {
+                for mat in &arc.materials {
+                    live_texture_ids.insert(mat.diffuse_texture.id);
+                    if let Some(h) = mat.normal_texture { live_texture_ids.insert(h.id); }
+                    if let Some(h) = mat.emissive_texture { live_texture_ids.insert(h.id); }
+                    if let Some(h) = mat.metallic_roughness_texture { live_texture_ids.insert(h.id); }
+                    if let Some(h) = mat.occlusion_texture { live_texture_ids.insert(h.id); }
+                }
                 return true;
             }
-            
-            let in_use = Arc::get_mut(arc).is_none();
-            if !in_use { counter += 1; }
-            in_use
+
+            counter += 1;
+            false
         });
         self.model_labels.retain(|_, handle| self.models.contains_key(&handle.id));
 
-        // textures
-        self.textures.retain(|_, arc| {
-            let in_use = Arc::get_mut(arc).is_none();
-            if !in_use { counter += 1; }
-            in_use
+        self.textures.retain(|id, arc| {
+            let is_null = *id == 0;
+            let is_live = live_texture_ids.contains(id);
+            let has_external_arc = Arc::get_mut(arc).is_none();
+
+            if is_null || is_live || has_external_arc {
+                return true;
+            }
+
+            counter += 1;
+            false
         });
         self.texture_labels.retain(|_, handle| self.textures.contains_key(&handle.id));
 
         counter
     }
 
+    /// Flushes away all unused assets using Arc strong-count only (no ECS awareness).
+    /// Prefer `flush_unused_with_live_ids` when the ECS world is accessible.
+    pub fn flush_unused(&mut self) -> usize {
+        self.flush_unused_with_live_ids(&HashSet::new())
+    }
+
     pub fn flush_everything(&mut self) {
+        log::debug!("Flushing everything");
         self.models.clear();
         self.model_labels.clear();
         self.textures.clear();
diff --git a/crates/dropbear-engine/src/entity.rs b/crates/dropbear-engine/src/entity.rs
index 6aabe11..8462d4f 100644
--- a/crates/dropbear-engine/src/entity.rs
+++ b/crates/dropbear-engine/src/entity.rs
@@ -74,89 +74,158 @@ pub fn inspect_rotation_dquat(
 
     match mode {
         RotationEditorMode::EulerDegrees => {
-            let (mut x, mut y, mut z) = rotation.to_euler(glam::EulerRot::XYZ);
-            x = x.to_degrees();
-            y = y.to_degrees();
-            z = z.to_degrees();
-
-            let changed = ui
-                .horizontal(|ui| {
-                    ui.colored_label(egui::Color32::from_rgb(200, 80, 80), "X:");
-                    let cx = ui
-                        .add(
-                            egui::DragValue::new(&mut x)
-                                .speed(1.0)
-                                .suffix("°")
-                                .fixed_decimals(1),
-                        )
-                        .changed();
-
-                    ui.colored_label(egui::Color32::from_rgb(80, 200, 80), "Y:");
-                    let cy = ui
-                        .add(
-                            egui::DragValue::new(&mut y)
-                                .speed(1.0)
-                                .suffix("°")
-                                .fixed_decimals(1),
-                        )
-                        .changed();
-
-                    ui.colored_label(egui::Color32::from_rgb(80, 120, 220), "Z:");
-                    let cz = ui
-                        .add(
-                            egui::DragValue::new(&mut z)
-                                .speed(1.0)
-                                .suffix("°")
-                                .fixed_decimals(1),
-                        )
-                        .changed();
-
-                    cx || cy || cz
-                })
-                .inner;
+            let euler_id = mode_id.with("euler_deg");
+            let last_quat_id = mode_id.with("euler_deg_last_quat");
+
+            let last_quat = ui.ctx().data(|d| d.get_temp::<DQuat>(last_quat_id));
+            let external_change = last_quat.map_or(true, |q| {
+                (q.x - rotation.x).abs() > 1e-10
+                    || (q.y - rotation.y).abs() > 1e-10
+                    || (q.z - rotation.z).abs() > 1e-10
+                    || (q.w - rotation.w).abs() > 1e-10
+            });
+
+            let stored_euler = ui.ctx().data(|d| d.get_temp::<[f64; 3]>(euler_id));
+            let [mut x, mut y, mut z] = if external_change || stored_euler.is_none() {
+                let (ex, ey, ez) = rotation.to_euler(glam::EulerRot::XYZ);
+                [ex.to_degrees(), ey.to_degrees(), ez.to_degrees()]
+            } else {
+                stored_euler.unwrap()
+            };
+
+            let mut changed = false;
+            let mut any_dragging = false;
+
+            ui.horizontal(|ui| {
+                ui.colored_label(egui::Color32::from_rgb(200, 80, 80), "X:");
+                let rx = ui.add(
+                    egui::DragValue::new(&mut x)
+                        .speed(1.0)
+                        .suffix("°")
+                        .fixed_decimals(1),
+                );
+                changed |= rx.changed();
+                any_dragging |= rx.dragged();
 
+                ui.colored_label(egui::Color32::from_rgb(80, 200, 80), "Y:");
+                let ry = ui.add(
+                    egui::DragValue::new(&mut y)
+                        .speed(1.0)
+                        .suffix("°")
+                        .fixed_decimals(1),
+                );
+                changed |= ry.changed();
+                any_dragging |= ry.dragged();
+
+                ui.colored_label(egui::Color32::from_rgb(80, 120, 220), "Z:");
+                let rz = ui.add(
+                    egui::DragValue::new(&mut z)
+                        .speed(1.0)
+                        .suffix("°")
+                        .fixed_decimals(1),
+                );
+                changed |= rz.changed();
+                any_dragging |= rz.dragged();
+            });
+
+            let hint_id = mode_id.with("euler_hint_rad");
             if changed {
-                *rotation = DQuat::from_euler(
+                let new_rot = DQuat::from_euler(
                     glam::EulerRot::XYZ,
                     x.to_radians(),
                     y.to_radians(),
                     z.to_radians(),
                 );
+                ui.ctx().data_mut(|d| {
+                    d.insert_temp(euler_id, [x, y, z]);
+                    d.insert_temp(last_quat_id, new_rot);
+                    d.insert_temp(hint_id, [x.to_radians(), y.to_radians(), z.to_radians()]);
+                });
+                *rotation = new_rot;
+            } else {
+                ui.ctx().data_mut(|d| {
+                    d.insert_temp(euler_id, [x, y, z]);
+                    d.insert_temp(hint_id, [x.to_radians(), y.to_radians(), z.to_radians()]);
+                    if !any_dragging {
+                        d.insert_temp(last_quat_id, *rotation);
+                    }
+                });
             }
             changed
         }
         RotationEditorMode::EulerRadians => {
-            let (mut x, mut y, mut z) = rotation.to_euler(glam::EulerRot::XYZ);
-            let changed = ui
-                .horizontal(|ui| {
-                    ui.colored_label(egui::Color32::from_rgb(200, 80, 80), "X:");
-                    let cx = ui
-                        .add(egui::DragValue::new(&mut x).speed(0.01).fixed_decimals(3))
-                        .changed();
-
-                    ui.colored_label(egui::Color32::from_rgb(80, 200, 80), "Y:");
-                    let cy = ui
-                        .add(egui::DragValue::new(&mut y).speed(0.01).fixed_decimals(3))
-                        .changed();
-
-                    ui.colored_label(egui::Color32::from_rgb(80, 120, 220), "Z:");
-                    let cz = ui
-                        .add(egui::DragValue::new(&mut z).speed(0.01).fixed_decimals(3))
-                        .changed();
-
-                    cx || cy || cz
-                })
-                .inner;
+            let euler_id = mode_id.with("euler_rad");
+            let last_quat_id = mode_id.with("euler_rad_last_quat");
+
+            let last_quat = ui.ctx().data(|d| d.get_temp::<DQuat>(last_quat_id));
+            let external_change = last_quat.map_or(true, |q| {
+                (q.x - rotation.x).abs() > 1e-10
+                    || (q.y - rotation.y).abs() > 1e-10
+                    || (q.z - rotation.z).abs() > 1e-10
+                    || (q.w - rotation.w).abs() > 1e-10
+            });
+
+            let stored_euler = ui.ctx().data(|d| d.get_temp::<[f64; 3]>(euler_id));
+            let [mut x, mut y, mut z] = if external_change || stored_euler.is_none() {
+                let (ex, ey, ez) = rotation.to_euler(glam::EulerRot::XYZ);
+                [ex, ey, ez]
+            } else {
+                stored_euler.unwrap()
+            };
+
+            let mut changed = false;
+            let mut any_dragging = false;
+
+            ui.horizontal(|ui| {
+                ui.colored_label(egui::Color32::from_rgb(200, 80, 80), "X:");
+                let rx = ui.add(
+                    egui::DragValue::new(&mut x)
+                        .speed(0.01)
+                        .fixed_decimals(3),
+                );
+                changed |= rx.changed();
+                any_dragging |= rx.dragged();
 
+                ui.colored_label(egui::Color32::from_rgb(80, 200, 80), "Y:");
+                let ry = ui.add(
+                    egui::DragValue::new(&mut y)
+                        .speed(0.01)
+                        .fixed_decimals(3),
+                );
+                changed |= ry.changed();
+                any_dragging |= ry.dragged();
+
+                ui.colored_label(egui::Color32::from_rgb(80, 120, 220), "Z:");
+                let rz = ui.add(
+                    egui::DragValue::new(&mut z)
+                        .speed(0.01)
+                        .fixed_decimals(3),
+                );
+                changed |= rz.changed();
+                any_dragging |= rz.dragged();
+            });
+
+            let hint_id = mode_id.with("euler_hint_rad");
             if changed {
-                *rotation = DQuat::from_euler(glam::EulerRot::XYZ, x, y, z);
+                let new_rot = DQuat::from_euler(glam::EulerRot::XYZ, x, y, z);
+                ui.ctx().data_mut(|d| {
+                    d.insert_temp(euler_id, [x, y, z]);
+                    d.insert_temp(last_quat_id, new_rot);
+                    d.insert_temp(hint_id, [x, y, z]);
+                });
+                *rotation = new_rot;
+            } else {
+                ui.ctx().data_mut(|d| {
+                    d.insert_temp(euler_id, [x, y, z]);
+                    d.insert_temp(hint_id, [x, y, z]);
+                    if !any_dragging {
+                        d.insert_temp(last_quat_id, *rotation);
+                    }
+                });
             }
             changed
         }
         RotationEditorMode::Quaternion => {
-            // Store raw (unnormalized) components in temp data keyed to this editor
-            // instance so that normalization on one frame doesn't cause other components
-            // to jump on the next frame while the user is still dragging.
             let raw_id = mode_id.with("raw_quat");
             let stored = ui.ctx().data(|d| d.get_temp::<[f64; 4]>(raw_id));
             let [mut x, mut y, mut z, mut w] =
@@ -188,13 +257,8 @@ pub fn inspect_rotation_dquat(
             });
 
             if any_dragging || changed {
-                // Keep our raw (possibly unnormalized) values in temp storage so the
-                // next frame doesn't re-read from the normalized rotation and make
-                // unedited components jump.
                 ui.ctx().data_mut(|d| d.insert_temp(raw_id, [x, y, z, w]));
             } else {
-                // Nothing is being edited — sync temp storage back to the real rotation
-                // so external changes (gizmo, script) are reflected.
                 ui.ctx()
                     .data_mut(|d| d.insert_temp(raw_id, [rotation.x, rotation.y, rotation.z, rotation.w]));
             }
diff --git a/crates/dropbear-engine/src/graphics.rs b/crates/dropbear-engine/src/graphics.rs
index 17b0048..d081ead 100644
--- a/crates/dropbear-engine/src/graphics.rs
+++ b/crates/dropbear-engine/src/graphics.rs
@@ -83,9 +83,10 @@ impl Instance {
     pub fn to_raw(&self) -> InstanceRaw {
         let model_matrix =
             DMat4::from_scale_rotation_translation(self.scale, self.rotation, self.position);
+        let normal_matrix = Mat3::from_mat4(model_matrix.as_mat4()).inverse().transpose();
         InstanceRaw {
             model: model_matrix.as_mat4().to_cols_array_2d(),
-            normal: Mat3::from_quat(self.rotation.as_quat()).to_cols_array_2d(),
+            normal: normal_matrix.to_cols_array_2d(),
         }
     }
 
diff --git a/crates/dropbear-engine/src/lib.rs b/crates/dropbear-engine/src/lib.rs
index 51d101c..c97fd51 100644
--- a/crates/dropbear-engine/src/lib.rs
+++ b/crates/dropbear-engine/src/lib.rs
@@ -319,7 +319,7 @@ impl BindGroupLayouts {
                         binding: 0,
                         visibility: wgpu::ShaderStages::FRAGMENT,
                         ty: wgpu::BindingType::Texture {
-                            sample_type: wgpu::TextureSampleType::Float { filterable: false },
+                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
                             view_dimension: wgpu::TextureViewDimension::Cube,
                             multisampled: false,
                         },
@@ -328,7 +328,7 @@ impl BindGroupLayouts {
                     wgpu::BindGroupLayoutEntry {
                         binding: 1,
                         visibility: wgpu::ShaderStages::FRAGMENT,
-                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
+                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
                         count: None,
                     },
                 ],
diff --git a/crates/dropbear-engine/src/shaders/equirectangular.wgsl b/crates/dropbear-engine/src/shaders/equirectangular.wgsl
index 440155f..8375750 100644
--- a/crates/dropbear-engine/src/shaders/equirectangular.wgsl
+++ b/crates/dropbear-engine/src/shaders/equirectangular.wgsl
@@ -12,7 +12,7 @@ var src: texture_2d<f32>;
 
 @group(0)
 @binding(1)
-var dst: texture_storage_2d_array<rgba32float, write>;
+var dst: texture_storage_2d_array<rgba16float, write>;
 
 @compute
 @workgroup_size(16, 16, 1)
diff --git a/crates/dropbear-engine/src/shaders/mip_generator.wgsl b/crates/dropbear-engine/src/shaders/mip_generator.wgsl
new file mode 100644
index 0000000..a018ac6
--- /dev/null
+++ b/crates/dropbear-engine/src/shaders/mip_generator.wgsl
@@ -0,0 +1,30 @@
+/// Generates mip level N from level N-1 of a cubemap stored as a D2Array texture.
+/// The src binding is a regular texture view of exactly mip level N-1 (base_mip_level = N-1,
+/// mip_level_count = 1).  Using textureLoad (not a ReadOnly storage texture) gives
+/// reliable cross-backend support since ReadOnly storage images are poorly supported.
+/// The dst binding is a WriteOnly storage view of mip level N.
+/// All 6 cube faces (array_layer_count = 6) are processed in the Z dimension.
+
+@group(0) @binding(0) var src: texture_2d_array<f32>;
+@group(0) @binding(1) var dst: texture_storage_2d_array<rgba16float, write>;
+
+@compute @workgroup_size(8, 8, 1)
+fn generate_mip(@builtin(global_invocation_id) id: vec3<u32>) {
+    let dst_size = textureDimensions(dst);
+    if id.x >= dst_size.x || id.y >= dst_size.y || id.z >= 6u {
+        return;
+    }
+
+    let src_x = i32(id.x) * 2;
+    let src_y = i32(id.y) * 2;
+    let layer = i32(id.z);
+
+    // Box filter: average 2×2 block.
+    // LOD 0 refers to the only mip level visible in the bound view (= the actual N-1 level).
+    let c00 = textureLoad(src, vec2<i32>(src_x,     src_y    ), layer, 0);
+    let c10 = textureLoad(src, vec2<i32>(src_x + 1, src_y    ), layer, 0);
+    let c01 = textureLoad(src, vec2<i32>(src_x,     src_y + 1), layer, 0);
+    let c11 = textureLoad(src, vec2<i32>(src_x + 1, src_y + 1), layer, 0);
+
+    textureStore(dst, vec2<i32>(i32(id.x), i32(id.y)), layer, (c00 + c10 + c01 + c11) * 0.25);
+}
diff --git a/crates/dropbear-engine/src/shaders/shader.wgsl b/crates/dropbear-engine/src/shaders/shader.wgsl
index c5ee895..bf45610 100644
--- a/crates/dropbear-engine/src/shaders/shader.wgsl
+++ b/crates/dropbear-engine/src/shaders/shader.wgsl
@@ -1,3 +1,5 @@
+const PI: f32 = 3.14159265358979;
+
 struct Globals {
     num_lights: u32,
     ambient_strength: f32,
@@ -187,93 +189,159 @@ fn vs_main(model: VertexInput, instance: InstanceInput) -> VertexOutput {
     let world_bitangent = normalize(cross(world_normal, world_tangent) * model.tangent.w);
 
     var out: VertexOutput;
-    out.clip_position      = u_camera.view_proj * world_position;
-    out.tex_coords         = model.tex_coords0;
-    out.world_normal       = world_normal;
-    out.world_position     = world_position.xyz;
-    out.world_tangent      = world_tangent;
-    out.world_bitangent    = world_bitangent;
+    out.clip_position       = u_camera.view_proj * world_position;
+    out.tex_coords          = model.tex_coords0;
+    out.world_normal        = world_normal;
+    out.world_position      = world_position.xyz;
+    out.world_tangent       = world_tangent;
+    out.world_bitangent     = world_bitangent;
     out.world_view_position = u_camera.view_pos.xyz;
     return out;
 }
 
-fn blinn_phong(
-    n: vec3<f32>,
-    l: vec3<f32>,
-    v: vec3<f32>,
-    albedo: vec3<f32>,
-    light_colour: vec3<f32>,
+fn distribution_ggx(n_dot_h: f32, roughness: f32) -> f32 {
+    let a  = roughness * roughness;
+    let a2 = a * a;
+    let d  = n_dot_h * n_dot_h * (a2 - 1.0) + 1.0;
+    return a2 / (PI * d * d);
+}
+
+fn geometry_schlick_ggx(n_dot_x: f32, roughness: f32) -> f32 {
+    let r = roughness + 1.0;
+    let k = (r * r) / 8.0;
+    return n_dot_x / (n_dot_x * (1.0 - k) + k);
+}
+
+fn geometry_smith(n_dot_v: f32, n_dot_l: f32, roughness: f32) -> f32 {
+    return geometry_schlick_ggx(n_dot_v, roughness)
+         * geometry_schlick_ggx(n_dot_l, roughness);
+}
+
+fn fresnel_schlick(cos_theta: f32, f0: vec3<f32>) -> vec3<f32> {
+    return f0 + (1.0 - f0) * pow(clamp(1.0 - cos_theta, 0.0, 1.0), 5.0);
+}
+
+fn fresnel_schlick_roughness(cos_theta: f32, f0: vec3<f32>, roughness: f32) -> vec3<f32> {
+    let r1 = vec3<f32>(1.0 - roughness);
+    return f0 + (max(r1, f0) - f0) * pow(clamp(1.0 - cos_theta, 0.0, 1.0), 5.0);
+}
+
+fn pbr_direct(
+    n:         vec3<f32>,
+    v:         vec3<f32>,
+    l:         vec3<f32>,
+    albedo:    vec3<f32>,
+    f0:        vec3<f32>,
+    roughness: f32,
+    metallic:  f32,
 ) -> vec3<f32> {
-    // diffuse
-    // 1.0 = light hits head-on
-    // 0.0 = grazing
-    // negative = behind
-    let ndotl = max(dot(n, l), 0.0);
-    let diffuse = albedo * light_colour * ndotl;
-
-    // specular
-    let h = normalize(l + v);
-    let ndoth = max(dot(n, h), 0.0);
-    let specular = light_colour * pow(ndoth, 32.0); // 32.0 = shininess
-
-    return diffuse + specular;
+    let h = normalize(v + l);
+
+    let n_dot_v = max(dot(n, v), 0.0001);
+    let n_dot_l = max(dot(n, l), 0.0);
+    let n_dot_h = max(dot(n, h), 0.0);
+    let h_dot_v = max(dot(h, v), 0.0);
+
+    let D = distribution_ggx(n_dot_h, roughness);
+    let G = geometry_smith(n_dot_v, n_dot_l, roughness);
+    let F = fresnel_schlick(h_dot_v, f0);
+
+    let numerator   = D * G * F;
+    let denominator = 4.0 * n_dot_v * n_dot_l + 0.0001;
+    let specular    = numerator / denominator;
+
+    let k_s = F;
+    let k_d = (1.0 - k_s) * (1.0 - metallic);
+
+    return (k_d * albedo / PI + specular) * n_dot_l;
 }
 
-// l will always be the same
-fn directional_light(
-    light: Light,
-    n: vec3<f32>,
-    v: vec3<f32>,
-    albedo: vec3<f32>,
+fn directional_light_pbr(
+    light:     Light,
+    n:         vec3<f32>,
+    v:         vec3<f32>,
+    albedo:    vec3<f32>,
+    f0:        vec3<f32>,
+    roughness: f32,
+    metallic:  f32,
 ) -> vec3<f32> {
-    // direction stored in light points towards light source
     let l = normalize(light.direction.xyz);
-    let light_colour = light.color.rgb;
-    return blinn_phong(n, l, v, albedo, light_colour);
+    return pbr_direct(n, v, l, albedo, f0, roughness, metallic) * light.color.rgb;
 }
 
-// light comes from a position and falls off with distance
-fn point_light(
-    light: Light,
-    n: vec3<f32>,
-    v: vec3<f32>,
-    albedo: vec3<f32>,
+fn point_light_pbr(
+    light:     Light,
+    n:         vec3<f32>,
+    v:         vec3<f32>,
+    albedo:    vec3<f32>,
+    f0:        vec3<f32>,
+    roughness: f32,
+    metallic:  f32,
     world_pos: vec3<f32>,
 ) -> vec3<f32> {
     let to_light = light.position.xyz - world_pos;
-    let l = normalize(to_light);
-    let dist = length(to_light);
-
-    let attenuation = 1.0 / (light.constant + light.lin * dist + light.quadratic * pow(dist, 2));
-    
-    let light_colour = light.color.rgb * attenuation;
-    return blinn_phong(n, l, v, albedo, light_colour);
+    let dist     = length(to_light);
+    let l        = to_light / dist;
+    let atten    = 1.0 / (light.constant + light.lin * dist + light.quadratic * dist * dist);
+    return pbr_direct(n, v, l, albedo, f0, roughness, metallic) * light.color.rgb * atten;
 }
 
-// same as point light, except in the shape of a cone and falls off with further distance
-fn spot_light(
-    light: Light,
-    n: vec3<f32>,
-    v: vec3<f32>,
-    albedo: vec3<f32>,
+fn spot_light_pbr(
+    light:     Light,
+    n:         vec3<f32>,
+    v:         vec3<f32>,
+    albedo:    vec3<f32>,
+    f0:        vec3<f32>,
+    roughness: f32,
+    metallic:  f32,
     world_pos: vec3<f32>,
 ) -> vec3<f32> {
     let to_light = light.position.xyz - world_pos;
-    let l = normalize(to_light);
-    let dist = length(to_light);
+    let dist     = length(to_light);
+    let l        = to_light / dist;
+    let atten    = 1.0 / (light.constant + light.lin * dist + light.quadratic * dist * dist);
+
+    let spot_dir   = normalize(light.direction.xyz);
+    let theta      = dot(-l, spot_dir);
+    let cone_factor = smoothstep(light.direction.w, light.cutoff, theta);
+
+    return pbr_direct(n, v, l, albedo, f0, roughness, metallic)
+         * light.color.rgb * atten * cone_factor;
+}
 
-    let attenuation = 1.0 / (light.constant + light.lin * dist + light.quadratic * pow(dist, 2));
+fn ibl(
+    n:            vec3<f32>,
+    v:            vec3<f32>,
+    albedo:       vec3<f32>,
+    f0:           vec3<f32>,
+    roughness:    f32,
+    metallic:     f32,
+) -> vec3<f32> {
+    let n_dot_v = max(dot(n, v), 0.0001);
+    let num_mips = f32(textureNumLevels(env_map));
+
+    // fresnel for smooth roughness fade
+    let F = fresnel_schlick_roughness(n_dot_v, f0, roughness);
+
+    let k_s = F;
+    let k_d = (1.0 - k_s) * (1.0 - metallic);
 
-    // how far off-center is the fragment from the spotlight's direction?
-    let spot_dir = normalize(light.direction.xyz);
-    let theta = dot(-l, spot_dir); // cosine from the angle of the center
+    // diffuse irradiance: sample the most-blurred mip to approximate
+    // hemisphere-integrated radiance
+    let irradiance  = textureSampleLevel(env_map, env_sampler, n, num_mips - 1.0).rgb;
+    let diffuse_ibl = k_d * albedo * irradiance;
 
-    let inner = light.cutoff;
-    let outer = light.direction.w;
-    let cone_factor = smoothstep(outer, inner, theta);
+    // specular radiance: rougher materials sample higher (blurrier) mip levels
+    let r              = reflect(-v, n);
+    let specular_mip   = roughness * roughness * (num_mips - 1.0);
+    let prefiltered    = textureSampleLevel(env_map, env_sampler, r, specular_mip).rgb;
 
-    let light_colour = light.color.rgb * attenuation * cone_factor;
-    return blinn_phong(n, l, v, albedo, light_colour);
+    // Analytic BRDF integration approximation (no LUT needed)
+    let env_brdf_x = exp(-6.9 * roughness * roughness * n_dot_v);
+    let env_brdf   = F * (1.0 - env_brdf_x) + f0 * env_brdf_x;
+    let specular_ibl = prefiltered * env_brdf;
+
+    return diffuse_ibl + specular_ibl;
 }
 
 fn get_normal(in: VertexOutput, uv: vec2<f32>) -> vec3<f32> {
@@ -289,10 +357,10 @@ fn get_normal(in: VertexOutput, uv: vec2<f32>) -> vec3<f32> {
         tangent_normal.z,
     );
 
-    let t = normalize(in.world_tangent);
-    let b = normalize(in.world_bitangent);
-    let n = normalize(in.world_normal);
-    let tbn = mat3x3<f32>(t, b, n);
+    let t   = normalize(in.world_tangent);
+    let b   = normalize(in.world_bitangent);
+    let nm  = normalize(in.world_normal);
+    let tbn = mat3x3<f32>(t, b, nm);
 
     return normalize(tbn * tangent_normal);
 }
@@ -300,34 +368,67 @@ fn get_normal(in: VertexOutput, uv: vec2<f32>) -> vec3<f32> {
 @fragment
 fn s_fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
     let uv = in.tex_coords * u_material.uv_tiling;
-    let albedo_sample = textureSample(t_diffuse, s_diffuse, uv);
 
-    // transparency
+    // albedo + alpha
+    let albedo_sample = textureSample(t_diffuse, s_diffuse, uv);
     if albedo_sample.a < u_material.alpha_cutoff {
         discard;
     }
 
     let albedo = albedo_sample.rgb * u_material.base_colour.rgb;
-    let v = normalize(u_camera.view_pos.xyz - in.world_position);
-    let n = get_normal(in, uv);
 
-    var total_light = vec3<f32>(0.0);
+    // metallic / roughness
+    // glTF convention: B = metallic, G = roughness (R = occlusion when packed).
+    var metallic  = u_material.metallic;
+    var roughness = u_material.roughness;
+    if u_material.has_metallic_texture != 0u {
+        let mr_sample = textureSample(t_metallic, s_metallic, uv);
+        metallic  *= mr_sample.b;
+        roughness *= mr_sample.g;
+    }
+    roughness = clamp(roughness, 0.04, 1.0);
+
+    // occlusion
+    var occlusion = 1.0;
+    if u_material.has_occlusion_texture != 0u {
+        let occ_sample = textureSample(t_occlusion, s_occlusion, uv);
+        occlusion = mix(1.0, occ_sample.r, u_material.occlusion_strength);
+    }
+
+    let n   = get_normal(in, uv);
+    let v   = normalize(u_camera.view_pos.xyz - in.world_position);
 
-    let ambient = albedo * u_globals.ambient_strength;
-    total_light += ambient;
+    // F0: dielectrics use 0.04, metals use their albedo colour
+    let f0 = mix(vec3<f32>(0.04), albedo, metallic);
 
+    // cook-torence
+    var lo = vec3<f32>(0.0);
     for (var i = 0u; i < u_globals.num_lights; i++) {
-        let light = s_light_array[i];
+        let light      = s_light_array[i];
         let light_type = u32(light.color.w);
 
         if light_type == 0u {
-            total_light += directional_light(light, n, v, albedo);
+            lo += directional_light_pbr(light, n, v, albedo, f0, roughness, metallic);
         } else if light_type == 1u {
-            total_light += point_light(light, n, v, albedo, in.world_position);
+            lo += point_light_pbr(light, n, v, albedo, f0, roughness, metallic, in.world_position);
         } else if light_type == 2u {
-            total_light += spot_light(light, n, v, albedo, in.world_position);
+            lo += spot_light_pbr(light, n, v, albedo, f0, roughness, metallic, in.world_position);
         }
     }
 
-    return vec4<f32>(total_light, albedo_sample.a);
+    // image-based lighting (env map)
+    let ambient_ibl = ibl(n, v, albedo, f0, roughness, metallic)
+                    * occlusion
+                    * u_globals.ambient_strength;
+
+    // emissive
+    var emissive = u_material.emissive * u_material.emissive_strength;
+    if u_material.has_emissive_texture != 0u {
+        emissive *= textureSample(t_emissive, s_emissive, uv).rgb;
+    }
+
+    // combine
+    let colour = lo + ambient_ibl + emissive;
+
+    return vec4<f32>(colour, albedo_sample.a);
 }
\ No newline at end of file
diff --git a/crates/dropbear-engine/src/sky.rs b/crates/dropbear-engine/src/sky.rs
index 333d178..bac163e 100644
--- a/crates/dropbear-engine/src/sky.rs
+++ b/crates/dropbear-engine/src/sky.rs
@@ -55,8 +55,8 @@ impl CubeTexture {
             address_mode_v: wgpu::AddressMode::ClampToEdge,
             address_mode_w: wgpu::AddressMode::ClampToEdge,
             mag_filter,
-            min_filter: wgpu::FilterMode::Nearest,
-            mipmap_filter: wgpu::FilterMode::Nearest,
+            min_filter: wgpu::FilterMode::Linear,
+            mipmap_filter: wgpu::FilterMode::Linear,
             ..Default::default()
         });
 
@@ -81,9 +81,12 @@ impl CubeTexture {
 }
 
 pub struct HdrLoader {
-    texture_format: wgpu::TextureFormat,
+    src_format: wgpu::TextureFormat,
+    dst_format: wgpu::TextureFormat,
     equirect_layout: wgpu::BindGroupLayout,
     equirect_to_cubemap: wgpu::ComputePipeline,
+    mip_gen_layout: wgpu::BindGroupLayout,
+    mip_gen_pipeline: wgpu::ComputePipeline,
 }
 
 impl HdrLoader {
@@ -91,7 +94,8 @@ impl HdrLoader {
         puffin::profile_function!();
         let module =
             device.create_shader_module(wgpu::include_wgsl!("shaders/equirectangular.wgsl"));
-        let texture_format = wgpu::TextureFormat::Rgba32Float;
+        let src_format = wgpu::TextureFormat::Rgba32Float;
+        let dst_format = wgpu::TextureFormat::Rgba16Float;
         let equirect_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
             label: Some("HdrLoader::equirect_layout"),
             entries: &[
@@ -110,7 +114,7 @@ impl HdrLoader {
                     visibility: wgpu::ShaderStages::COMPUTE,
                     ty: wgpu::BindingType::StorageTexture {
                         access: wgpu::StorageTextureAccess::WriteOnly,
-                        format: texture_format,
+                        format: dst_format,
                         view_dimension: wgpu::TextureViewDimension::D2Array,
                     },
                     count: None,
@@ -134,10 +138,62 @@ impl HdrLoader {
                 cache: None,
             });
 
+        let mip_gen_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+            label: Some("HdrLoader::mip_gen_layout"),
+            entries: &[
+                // Source mip level – read via textureLoad (no ReadOnly storage needed,
+                // avoids the poor cross-backend support for storage-image reads).
+                wgpu::BindGroupLayoutEntry {
+                    binding: 0,
+                    visibility: wgpu::ShaderStages::COMPUTE,
+                    ty: wgpu::BindingType::Texture {
+                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
+                        view_dimension: wgpu::TextureViewDimension::D2Array,
+                        multisampled: false,
+                    },
+                    count: None,
+                },
+                // Destination mip level – write via textureStore (storage WriteOnly).
+                wgpu::BindGroupLayoutEntry {
+                    binding: 1,
+                    visibility: wgpu::ShaderStages::COMPUTE,
+                    ty: wgpu::BindingType::StorageTexture {
+                        access: wgpu::StorageTextureAccess::WriteOnly,
+                        format: dst_format,
+                        view_dimension: wgpu::TextureViewDimension::D2Array,
+                    },
+                    count: None,
+                },
+            ],
+        });
+
+        let mip_gen_pipeline_layout =
+            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+                label: None,
+                bind_group_layouts: &[&mip_gen_layout],
+                push_constant_ranges: &[],
+            });
+
+        let mip_gen_module =
+            device.create_shader_module(wgpu::include_wgsl!("shaders/mip_generator.wgsl"));
+
+        let mip_gen_pipeline =
+            device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
+                label: Some("env cubemap mip generator"),
+                layout: Some(&mip_gen_pipeline_layout),
+                module: &mip_gen_module,
+                entry_point: Some("generate_mip"),
+                compilation_options: Default::default(),
+                cache: None,
+            });
+
         Self {
             equirect_to_cubemap,
-            texture_format,
+            src_format,
+            dst_format,
             equirect_layout,
+            mip_gen_layout,
+            mip_gen_pipeline,
         }
     }
 
@@ -176,7 +232,7 @@ impl HdrLoader {
 
         let src = TextureBuilder::new(&device)
             .size(meta.width, meta.height)
-            .format(loader.texture_format)
+            .format(loader.src_format)
             .usage(wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST)
             .mag_filter(wgpu::FilterMode::Linear)
             .build();
@@ -197,27 +253,25 @@ impl HdrLoader {
             src.size,
         );
 
+        // Calculate full mip chain count: floor(log2(dst_size)) + 1
+        let mip_count = (dst_size as f32).log2().floor() as u32 + 1;
+
         let dst = CubeTexture::create_2d(
             device,
             dst_size,
             dst_size,
-            loader.texture_format,
-            1,
-            // We are going to write to `dst` texture so we
-            // need to use a `STORAGE_BINDING`.
+            loader.dst_format,
+            mip_count,
             wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING,
-            wgpu::FilterMode::Nearest,
+            wgpu::FilterMode::Linear,
             label,
         );
 
         let dst_view = dst.texture().create_view(&wgpu::TextureViewDescriptor {
             label,
-            // Normally, you'd use `TextureViewDimension::Cube`
-            // for a cube texture, but we can't use that
-            // view dimension with a `STORAGE_BINDING`.
-            // We need to access the cube texture layers
-            // directly.
             dimension: Some(wgpu::TextureViewDimension::D2Array),
+            base_mip_level: 0,
+            mip_level_count: Some(1),
             ..Default::default()
         });
 
@@ -251,6 +305,59 @@ impl HdrLoader {
 
         queue.submit([encoder.finish()]);
 
+        for level in 1..mip_count {
+            let src_view = dst.texture().create_view(&wgpu::TextureViewDescriptor {
+                label: Some("mip src view"),
+                dimension: Some(wgpu::TextureViewDimension::D2Array),
+                base_mip_level: level - 1,
+                mip_level_count: Some(1),
+                base_array_layer: 0,
+                array_layer_count: Some(6),
+                ..Default::default()
+            });
+            let dst_mip_view = dst.texture().create_view(&wgpu::TextureViewDescriptor {
+                label: Some("mip dst view"),
+                dimension: Some(wgpu::TextureViewDimension::D2Array),
+                base_mip_level: level,
+                mip_level_count: Some(1),
+                base_array_layer: 0,
+                array_layer_count: Some(6),
+                ..Default::default()
+            });
+
+            let mip_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
+                label: Some("mip gen bind group"),
+                layout: &loader.mip_gen_layout,
+                entries: &[
+                    wgpu::BindGroupEntry {
+                        binding: 0,
+                        resource: wgpu::BindingResource::TextureView(&src_view),
+                    },
+                    wgpu::BindGroupEntry {
+                        binding: 1,
+                        resource: wgpu::BindingResource::TextureView(&dst_mip_view),
+                    },
+                ],
+            });
+
+            let mut mip_encoder =
+                device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
+                    label: Some("mip gen encoder"),
+                });
+            {
+                let mut pass = mip_encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
+                    label: Some("mip gen pass"),
+                    timestamp_writes: None,
+                });
+                pass.set_pipeline(&loader.mip_gen_pipeline);
+                pass.set_bind_group(0, &mip_bind_group, &[]);
+                let mip_dim = (dst_size >> level).max(1);
+                let workgroups = (mip_dim + 7) / 8;
+                pass.dispatch_workgroups(workgroups, workgroups, 6);
+            }
+            queue.submit([mip_encoder.finish()]);
+        }
+
         Ok(dst)
     }
 }
@@ -292,7 +399,7 @@ impl SkyPipeline {
                     binding: 0,
                     visibility: wgpu::ShaderStages::FRAGMENT,
                     ty: wgpu::BindingType::Texture {
-                        sample_type: wgpu::TextureSampleType::Float { filterable: false },
+                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                         view_dimension: wgpu::TextureViewDimension::Cube,
                         multisampled: false,
                     },
@@ -301,7 +408,7 @@ impl SkyPipeline {
                 wgpu::BindGroupLayoutEntry {
                     binding: 1,
                     visibility: wgpu::ShaderStages::FRAGMENT,
-                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
+                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
                     count: None,
                 },
             ],
diff --git a/crates/eucalyptus-core/src/lighting.rs b/crates/eucalyptus-core/src/lighting.rs
index d5d336e..c3c6d2d 100644
--- a/crates/eucalyptus-core/src/lighting.rs
+++ b/crates/eucalyptus-core/src/lighting.rs
@@ -179,7 +179,26 @@ impl InspectableComponent for Light {
                         direction = LIGHT_FORWARD_AXIS;
                     }
 
-                    let mut rotation = DQuat::from_rotation_arc(LIGHT_FORWARD_AXIS, direction);
+                    let light_rot_cache_id =
+                        egui::Id::new(("light_quat_cache", entity.to_bits()));
+                    let mut rotation = {
+                        let cached =
+                            yueye.ctx().data(|d| d.get_temp::<DQuat>(light_rot_cache_id));
+                        if let Some(cached_quat) = cached {
+                            let cached_fwd =
+                                (cached_quat * LIGHT_FORWARD_AXIS).normalize_or_zero();
+                            if cached_fwd.dot(direction) > 0.999_999 {
+                                // Direction unchanged externally; keep the accumulated quat.
+                                cached_quat
+                            } else {
+                                // External change (script/physics moved the light); rebuild.
+                                DQuat::from_rotation_arc(LIGHT_FORWARD_AXIS, direction)
+                            }
+                        } else {
+                            DQuat::from_rotation_arc(LIGHT_FORWARD_AXIS, direction)
+                        }
+                    };
+
                     let mut changed = inspect_rotation_dquat(
                         yueye,
                         ("light_rotation", entity.to_bits()),
@@ -187,11 +206,18 @@ impl InspectableComponent for Light {
                     );
 
                     if changed {
-                        self.component.direction = (rotation * LIGHT_FORWARD_AXIS).normalize_or_zero();
+                        yueye
+                            .ctx()
+                            .data_mut(|d| d.insert_temp(light_rot_cache_id, rotation));
+                        self.component.direction =
+                            (rotation * LIGHT_FORWARD_AXIS).normalize_or_zero();
                     }
 
                     if yueye.button("Reset Rotation").clicked() {
                         self.component.direction = LIGHT_FORWARD_AXIS;
+                        yueye.ctx().data_mut(|d| {
+                            d.insert_temp(light_rot_cache_id, DQuat::IDENTITY)
+                        });
                         changed = true;
                     }
 
@@ -247,7 +273,7 @@ impl InspectableComponent for Light {
 
                 ui.horizontal(|ui| {
                     ui.label("Intensity");
-                    ui.add(DragValue::new(&mut self.component.intensity).speed(0.05));
+                    ui.add(DragValue::new(&mut self.component.intensity).speed(0.05).range(0.0..=f64::MAX));
                 });
 
                 if matches!(
@@ -277,11 +303,11 @@ impl InspectableComponent for Light {
                 if matches!(self.component.light_type, LightType::Spot) {
                     ui.horizontal(|ui| {
                         ui.label("Cutoff");
-                        ui.add(DragValue::new(&mut self.component.cutoff_angle).speed(0.1));
+                        ui.add(DragValue::new(&mut self.component.cutoff_angle).speed(0.1).range(0.0..=180.0));
                     });
                     ui.horizontal(|ui| {
                         ui.label("Outer Cutoff");
-                        ui.add(DragValue::new(&mut self.component.outer_cutoff_angle).speed(0.1));
+                        ui.add(DragValue::new(&mut self.component.outer_cutoff_angle).speed(0.1).range(0.0..=180.0));
                     });
 
                     if self.component.outer_cutoff_angle <= self.component.cutoff_angle {
@@ -294,9 +320,9 @@ impl InspectableComponent for Light {
                 ui.checkbox(&mut self.component.cast_shadows, "Cast Shadows");
                 ui.horizontal(|ui| {
                     ui.label("Depth");
-                    ui.add(DragValue::new(&mut self.component.depth.start).speed(0.1));
+                    ui.add(DragValue::new(&mut self.component.depth.start).speed(0.1).range(0.0..=f64::MAX));
                     ui.label("..");
-                    ui.add(DragValue::new(&mut self.component.depth.end).speed(0.1));
+                    ui.add(DragValue::new(&mut self.component.depth.end).speed(0.1).range(0.0..=f64::MAX));
                 });
 
                 if self.component.depth.end < self.component.depth.start {
diff --git a/crates/eucalyptus-core/src/physics/collider.rs b/crates/eucalyptus-core/src/physics/collider.rs
index 26edf95..a2c3387 100644
--- a/crates/eucalyptus-core/src/physics/collider.rs
+++ b/crates/eucalyptus-core/src/physics/collider.rs
@@ -348,8 +348,15 @@ impl Collider {
                 self.rotation[2] as f64,
             );
             if inspect_rotation_dquat(ui, "collider_local_rotation", &mut rotation) {
-                let (x, y, z) = rotation.to_euler(glam::EulerRot::XYZ);
-                self.rotation = [x as f32, y as f32, z as f32];
+                let hint_id = ui
+                    .make_persistent_id(("rotation_mode", "collider_local_rotation"))
+                    .with("euler_hint_rad");
+                if let Some([x, y, z]) = ui.ctx().data(|d| d.get_temp::<[f64; 3]>(hint_id)) {
+                    self.rotation = [x as f32, y as f32, z as f32];
+                } else {
+                    let (x, y, z) = rotation.to_euler(glam::EulerRot::XYZ);
+                    self.rotation = [x as f32, y as f32, z as f32];
+                }
             }
         });
     }
diff --git a/crates/eucalyptus-editor/src/editor/mod.rs b/crates/eucalyptus-editor/src/editor/mod.rs
index f8b1629..c070f39 100644
--- a/crates/eucalyptus-editor/src/editor/mod.rs
+++ b/crates/eucalyptus-editor/src/editor/mod.rs
@@ -79,7 +79,6 @@ use dropbear_engine::multisampling::AntiAliasingMode;
 use winit::window::{CursorGrabMode, WindowAttributes};
 use winit::{keyboard::KeyCode, window::Window};
 use dropbear_engine::animation::{MorphTargetInfo, MAX_MORPH_WEIGHTS};
-use dropbear_engine::asset::ASSET_REGISTRY;
 use crate::editor::page::EditorTabVisibility;
 use crate::editor::ui::UiEditor;
 
@@ -934,9 +933,6 @@ impl Editor {
             .and_then(|stem| stem.to_str())
             .ok_or_else(|| anyhow::anyhow!("Scene file name is invalid"))?;
 
-        {
-            ASSET_REGISTRY.write().flush_everything();
-        }
         self.queue_scene_load_by_name(scene_name)?;
         info!("Queued scene '{}' for loading", scene_name);
         Ok(())
@@ -1194,9 +1190,7 @@ impl Editor {
 
                     ui.menu_button("Assets", |ui| {
                         if ui.button("Flush unused assets").clicked() {
-                            let mut asset = ASSET_REGISTRY.write();
-                            let count = asset.flush_unused();
-                            success!("Flushed {} unused assets", count);
+                            self.signal.push_back(Signal::FlushUnusedAssets);
                         }
                     });
                     ui.menu_button("Help", |ui| {
@@ -1881,6 +1875,7 @@ pub enum Signal {
     Undo,
     Play,
     StopPlaying,
+    FlushUnusedAssets,
     /// Adds a new component instance using the async init pipeline.
     AddComponent(hecs::Entity, Box<dyn SerializedComponent>),
     RequestNewWindow(WindowData),
diff --git a/crates/eucalyptus-editor/src/signal.rs b/crates/eucalyptus-editor/src/signal.rs
index 6c60a4b..db35b53 100644
--- a/crates/eucalyptus-editor/src/signal.rs
+++ b/crates/eucalyptus-editor/src/signal.rs
@@ -1,5 +1,7 @@
 use crate::editor::{AssetClipboard, Editor, EditorState, Signal};
 use crate::spawn::{PendingSpawn, push_pending_spawn};
+use dropbear_engine::asset::ASSET_REGISTRY;
+use dropbear_engine::entity::MeshRenderer;
 use dropbear_engine::graphics::SharedGraphicsContext;
 use egui::Align2;
 use eucalyptus_core::camera::{CameraComponent, CameraType};
@@ -7,7 +9,7 @@ use eucalyptus_core::scene::SceneEntity;
 use eucalyptus_core::scripting::{BuildStatus, build_jvm};
 use eucalyptus_core::states::{Label, PROJECT};
 use eucalyptus_core::{fatal, info, success, success_without_console, warn, warn_without_console};
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
 use std::fs;
 use std::path::PathBuf;
 use std::sync::Arc;
@@ -519,7 +521,7 @@ impl SignalController for Editor {
                     }
 
                     if matches!(self.editor_state, EditorState::Building) || self.show_build_error_window {
-                        self.signal.push_back(Signal::Play);
+                        requeue.push(Signal::Play);
                     }
                     Ok(())
                 }
@@ -610,6 +612,20 @@ impl SignalController for Editor {
                     
                     Ok(())
                 }
+                Signal::FlushUnusedAssets => {
+                    let live_model_ids: HashSet<u64> = self
+                        .world
+                        .query::<&MeshRenderer>()
+                        .iter()
+                        .map(|mr| mr.model().id)
+                        .collect();
+
+                    let mut asset = ASSET_REGISTRY.write();
+                    let count = asset.flush_unused_with_live_ids(&live_model_ids);
+                    success!("Flushed {} unused assets", count);
+
+                    Ok(())
+                }
                 Signal::ReloadWGPUData { skybox_texture } => {
                     self.main_render_pipeline = None;
                     self.light_cube_pipeline = None;