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