tirbofish/dropbear · commit
9d4eb6d1b7b0058e5ec70c50e198b7c88da70874
feature: pbr (i hope)
Signature present but could not be verified.
Unverified
@@ -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" @@ -57,3 +57,5 @@ rfd.workspace = true [build-dependencies] slank = { path = "../slank", features = ["download-slang"] } +naga.workspace = true +anyhow.workspace = true @@ -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(()) } @@ -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(); @@ -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])); } @@ -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(), } } @@ -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, }, ], @@ -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) @@ -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); +} @@ -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); } @@ -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, }, ], @@ -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 { @@ -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]; + } } }); } @@ -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), @@ -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;