From dffd731b87ab62fa9ac1cbff3d7dea35d338a97a Mon Sep 17 00:00:00 2001 From: Jonas H Date: Thu, 2 Apr 2026 16:54:23 +0200 Subject: [PATCH] particles --- Cargo.toml | 1 + src/components/dialog.rs | 2 + src/components/mod.rs | 2 + src/components/particle.rs | 18 +++ src/main.rs | 34 ++++- src/paths.rs | 5 + src/render/global.rs | 3 + src/render/mod.rs | 25 +++- src/render/particle_pipeline.rs | 241 +++++++++++++++++++++++++++++++ src/render/particle_types.rs | 89 ++++++++++++ src/shaders/particle.wgsl | 81 +++++++++++ src/systems/dialog_projectile.rs | 28 +++- src/systems/dialog_render.rs | 26 +--- src/systems/dialog_system.rs | 23 +-- src/systems/mod.rs | 4 + src/systems/particle.rs | 196 +++++++++++++++++++++++++ src/world.rs | 6 + 17 files changed, 739 insertions(+), 45 deletions(-) create mode 100644 src/components/particle.rs create mode 100644 src/render/particle_pipeline.rs create mode 100644 src/render/particle_types.rs create mode 100644 src/shaders/particle.wgsl create mode 100644 src/systems/particle.rs diff --git a/Cargo.toml b/Cargo.toml index 16aa48f..7dc8749 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ serde_json = "1.0" bladeink = "1.2" wesl = "0.2" ab_glyph = "0.2" +rand = "0.9" [build-dependencies] wesl = "0.2" diff --git a/src/components/dialog.rs b/src/components/dialog.rs index bfdfbcc..d46cfe7 100644 --- a/src/components/dialog.rs +++ b/src/components/dialog.rs @@ -1,4 +1,5 @@ use bladeink::story::Story; +use glam::Vec3; use crate::entity::EntityHandle; @@ -56,6 +57,7 @@ pub struct DialogProjectileComponent pub bubble_entity: EntityHandle, pub correct_parry: ParryButton, pub parry_window_open: bool, + pub velocity: Vec3, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] diff --git a/src/components/mod.rs b/src/components/mod.rs index 7933d28..b5064a6 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -9,6 +9,7 @@ pub mod lights; pub mod mesh; pub mod movement; pub mod noclip; +pub mod particle; pub mod physics; pub mod player_states; pub mod rotate; @@ -26,6 +27,7 @@ pub use input::InputComponent; pub use jump::JumpComponent; pub use mesh::MeshComponent; pub use movement::MovementComponent; +pub use particle::{ParticleEmitterConfig, SpawnParticleIntent}; pub use physics::PhysicsComponent; pub use rotate::RotateComponent; pub use tree_instances::TreeInstancesComponent; diff --git a/src/components/particle.rs b/src/components/particle.rs new file mode 100644 index 0000000..98b2e3b --- /dev/null +++ b/src/components/particle.rs @@ -0,0 +1,18 @@ +pub struct ParticleEmitterConfig +{ + pub burst_count: u32, + pub lifetime: std::ops::Range, + pub speed: std::ops::Range, + pub direction: Option, + pub direction_spread: f32, + pub gravity: f32, + pub size: std::ops::Range, + pub color_start: [f32; 4], + pub color_end: [f32; 4], +} + +pub struct SpawnParticleIntent +{ + pub origin: glam::Vec3, + pub config: ParticleEmitterConfig, +} diff --git a/src/main.rs b/src/main.rs index 3f81cd5..79a6773 100755 --- a/src/main.rs +++ b/src/main.rs @@ -27,13 +27,15 @@ use crate::editor::{editor_loop, EditorState, FrameStats}; use crate::entity::EntityHandle; use crate::loaders::scene::Space; use crate::physics::PhysicsManager; +use crate::render::particle_types::ParticleInstanceRaw; use crate::render::snow::{SnowConfig, SnowLayer}; use crate::systems::{ camera_follow_system, camera_ground_clamp_system, camera_input_system, camera_intent_system, - camera_noclip_system, camera_transition_system, camera_view_matrix, + camera_noclip_system, camera_transition_system, camera_view_matrix, collect_instances, dialog_bubble_render_system, dialog_camera_system, dialog_camera_transition_system, - dialog_projectile_system, dialog_system, physics_sync_system, player_input_system, - render_system, rotate_system, snow_system, spotlight_sync_system, state_machine_physics_system, + dialog_projectile_system, dialog_system, particle_intent_system, particle_update_system, + physics_sync_system, player_input_system, render_system, rotate_system, snow_system, + spawn_snow_particles, spotlight_sync_system, state_machine_physics_system, state_machine_system, tree_dissolve_update_system, tree_instance_buffer_update_system, tree_occlusion_system, trigger_system, }; @@ -311,7 +313,13 @@ fn handle_editor_pick(game: &mut Game, x: f32, y: f32) render::set_selected_entity(game.editor.selected_entity); } -fn submit_frame(game: &mut Game, draw_calls: &[render::DrawCall], time: f32, delta: f32) +fn submit_frame( + game: &mut Game, + draw_calls: &[render::DrawCall], + particle_instances: &[ParticleInstanceRaw], + time: f32, + delta: f32, +) { let (camera_entity, camera_component) = match game.world.active_camera() { @@ -344,6 +352,7 @@ fn submit_frame(game: &mut Game, draw_calls: &[render::DrawCall], time: f32, del draw_calls, &billboard_calls, &text_vertices, + particle_instances, time, delta, game.world.debug_mode, @@ -448,6 +457,14 @@ fn main() -> Result<(), Box> snow_system(&mut game.world); + particle_intent_system(&mut game.world); + particle_update_system(&mut game.world, delta); + let particle_cam_pos = game.world.active_camera_position(); + if let Some(ref mut buffers) = game.world.particle_buffers + { + spawn_snow_particles(buffers, particle_cam_pos, delta); + } + // --- draw call collection --- let mut draw_calls = render_system(&game.world); if let Some(ref snow_layer) = game.world.snow_layer @@ -464,8 +481,15 @@ fn main() -> Result<(), Box> game.stats.fps = 1.0 / delta; game.stats.frame_ms = delta * 1000.0; + let particle_instances: Vec = game + .world + .particle_buffers + .as_mut() + .map(|b| collect_instances(b).to_vec()) + .unwrap_or_default(); + // --- render --- - submit_frame(&mut game, &draw_calls, time, delta); + submit_frame(&mut game, &draw_calls, &particle_instances, time, delta); // --- end frame --- game.input_state.clear_just_pressed(); diff --git a/src/paths.rs b/src/paths.rs index 41a1abc..c306364 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -102,6 +102,11 @@ pub mod shaders format!("{}/text.wgsl", SHADERS_DIR) } + pub fn particle() -> String + { + format!("{}/particle.wgsl", SHADERS_DIR) + } + pub const SHADOW_PACKAGE: &str = "package::shadow"; pub const MAIN_PACKAGE: &str = "package::main"; pub const SNOW_LIGHT_ACCUMULATION_PACKAGE: &str = "package::snow_light_accumulation"; diff --git a/src/render/global.rs b/src/render/global.rs index cd8bcf3..c812852 100644 --- a/src/render/global.rs +++ b/src/render/global.rs @@ -9,6 +9,7 @@ use std::cell::RefCell; use crate::debug::DebugMode; use crate::entity::EntityHandle; +use super::particle_types::ParticleInstanceRaw; use super::text_pipeline::TextVertex; use super::{BillboardDrawCall, DrawCall, FontAtlas, Renderer, Spotlight}; @@ -115,6 +116,7 @@ pub fn render( draw_calls: &[DrawCall], billboard_calls: &[BillboardDrawCall], text_vertices: &[TextVertex], + particle_instances: &[ParticleInstanceRaw], time: f32, delta_time: f32, debug_mode: DebugMode, @@ -129,6 +131,7 @@ pub fn render( draw_calls, billboard_calls, text_vertices, + particle_instances, time, delta_time, debug_mode, diff --git a/src/render/mod.rs b/src/render/mod.rs index 354e9dc..9005ae5 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -7,6 +7,8 @@ mod types; pub mod billboard; pub mod font_atlas; +pub mod particle_pipeline; +pub mod particle_types; pub mod snow; pub mod snow_light; pub mod text_pipeline; @@ -18,6 +20,8 @@ pub use global::{ set_terrain_data, update_spotlights, with_device, with_font_atlas, with_queue, with_surface_format, }; +pub use particle_pipeline::ParticlePipeline; +pub use particle_types::ParticleInstanceRaw; pub use text_pipeline::TextVertex; pub use types::{DrawCall, Pipeline, Spotlight, SpotlightRaw, Uniforms, MAX_SPOTLIGHTS}; @@ -51,6 +55,7 @@ pub struct Renderer debug_lines_pipeline: Option, debug_overlay: Option, billboard_pipeline: BillboardPipeline, + particle_pipeline: ParticlePipeline, font_atlas: font_atlas::FontAtlas, text_pipeline: text_pipeline::TextPipeline, wireframe_supported: bool, @@ -527,6 +532,7 @@ impl Renderer )); let billboard_pipeline = BillboardPipeline::new(&device, config.format); + let particle_pipeline = ParticlePipeline::new(&device, config.format); let font_atlas = font_atlas::FontAtlas::load(&device, &queue); let text_pipeline = text_pipeline::TextPipeline::new(&device, config.format, &font_atlas); @@ -560,6 +566,7 @@ impl Renderer debug_lines_pipeline, debug_overlay, billboard_pipeline, + particle_pipeline, font_atlas, text_pipeline, wireframe_supported, @@ -607,6 +614,7 @@ impl Renderer draw_calls: &[DrawCall], billboard_calls: &[BillboardDrawCall], text_vertices: &[text_pipeline::TextVertex], + particle_instances: &[ParticleInstanceRaw], time: f32, delta_time: f32, debug_mode: DebugMode, @@ -1068,16 +1076,29 @@ impl Renderer billboard_calls, ); - let view_proj = (*projection * *view).to_cols_array_2d(); + let view_proj_mat = *projection * *view; self.text_pipeline.render( &mut overlay_encoder, &self.queue, &screen_view, &self.fullres_depth_view, text_vertices, - view_proj, + view_proj_mat.to_cols_array_2d(), ); + if !particle_instances.is_empty() + { + self.particle_pipeline.render( + &mut overlay_encoder, + &self.queue, + &screen_view, + &self.fullres_depth_view, + particle_instances, + view_proj_mat, + *view, + ); + } + self.queue.submit(std::iter::once(overlay_encoder.finish())); frame } diff --git a/src/render/particle_pipeline.rs b/src/render/particle_pipeline.rs new file mode 100644 index 0000000..f990afe --- /dev/null +++ b/src/render/particle_pipeline.rs @@ -0,0 +1,241 @@ +use std::mem::size_of; +use std::num::NonZeroU64; + +use bytemuck::{Pod, Zeroable}; +use wgpu::util::DeviceExt; + +use crate::paths; +use crate::render::particle_types::{ParticleInstanceRaw, ParticleVertex, MAX_PARTICLES}; + +#[repr(C)] +#[derive(Copy, Clone, Pod, Zeroable)] +struct ParticleUniforms +{ + view_proj: [[f32; 4]; 4], + camera_right: [f32; 3], + _pad0: f32, + camera_up: [f32; 3], + _pad1: f32, +} + +pub struct ParticlePipeline +{ + pipeline: wgpu::RenderPipeline, + vertex_buffer: wgpu::Buffer, + index_buffer: wgpu::Buffer, + instance_buffer: wgpu::Buffer, + uniform_buffer: wgpu::Buffer, + bind_group: wgpu::BindGroup, +} + +impl ParticlePipeline +{ + pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self + { + let shader_source = std::fs::read_to_string(&paths::shaders::particle()) + .expect("Failed to read particle.wgsl"); + + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Particle Shader"), + source: wgpu::ShaderSource::Wgsl(shader_source.into()), + }); + + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Particle Bind Group Layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: NonZeroU64::new(size_of::() as u64), + }, + count: None, + }], + }); + + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Particle Uniform Buffer"), + size: 256, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Particle Bind Group"), + layout: &bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buffer.as_entire_binding(), + }], + }); + + let vertices: [ParticleVertex; 4] = [ + ParticleVertex { + position: [-0.5, 0.5, 0.0], + uv: [0.0, 0.0], + }, + ParticleVertex { + position: [0.5, 0.5, 0.0], + uv: [1.0, 0.0], + }, + ParticleVertex { + position: [0.5, -0.5, 0.0], + uv: [1.0, 1.0], + }, + ParticleVertex { + position: [-0.5, -0.5, 0.0], + uv: [0.0, 1.0], + }, + ]; + + let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Particle Vertex Buffer"), + contents: bytemuck::cast_slice(&vertices), + usage: wgpu::BufferUsages::VERTEX, + }); + + let indices: [u16; 6] = [0, 1, 2, 2, 3, 0]; + let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Particle Index Buffer"), + contents: bytemuck::cast_slice(&indices), + usage: wgpu::BufferUsages::INDEX, + }); + + let instance_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Particle Instance Buffer"), + size: (MAX_PARTICLES * size_of::()) as wgpu::BufferAddress, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Particle Pipeline Layout"), + bind_group_layouts: &[&bind_group_layout], + immediate_size: 0, + }); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Particle Pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[ParticleVertex::desc(), ParticleInstanceRaw::desc()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: false, + depth_compare: wgpu::CompareFunction::LessEqual, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }); + + Self { + pipeline, + vertex_buffer, + index_buffer, + instance_buffer, + uniform_buffer, + bind_group, + } + } + + pub fn render( + &self, + encoder: &mut wgpu::CommandEncoder, + queue: &wgpu::Queue, + color_view: &wgpu::TextureView, + depth_view: &wgpu::TextureView, + instances: &[ParticleInstanceRaw], + view_proj: glam::Mat4, + view: glam::Mat4, + ) + { + if instances.is_empty() + { + return; + } + + let num_instances = instances.len().min(MAX_PARTICLES); + queue.write_buffer( + &self.instance_buffer, + 0, + bytemuck::cast_slice(&instances[..num_instances]), + ); + + let inv_view = view.inverse(); + let camera_right = inv_view.x_axis.truncate(); + let camera_up = inv_view.y_axis.truncate(); + + let uniforms = ParticleUniforms { + view_proj: view_proj.to_cols_array_2d(), + camera_right: camera_right.into(), + _pad0: 0.0, + camera_up: camera_up.into(), + _pad1: 0.0, + }; + queue.write_buffer(&self.uniform_buffer, 0, bytemuck::cast_slice(&[uniforms])); + + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Particle Pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: color_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &self.bind_group, &[]); + pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); + pass.set_vertex_buffer(1, self.instance_buffer.slice(..)); + pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16); + pass.draw_indexed(0..6, 0, 0..num_instances as u32); + } + } +} diff --git a/src/render/particle_types.rs b/src/render/particle_types.rs new file mode 100644 index 0000000..75bd16e --- /dev/null +++ b/src/render/particle_types.rs @@ -0,0 +1,89 @@ +use bytemuck::{Pod, Zeroable}; + +pub const MAX_PARTICLES: usize = 4096; + +#[repr(C)] +#[derive(Copy, Clone, Pod, Zeroable)] +pub struct ParticleVertex +{ + pub position: [f32; 3], + pub uv: [f32; 2], +} + +impl ParticleVertex +{ + pub fn desc() -> wgpu::VertexBufferLayout<'static> + { + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[ + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x3, + }, + wgpu::VertexAttribute { + offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress, + shader_location: 1, + format: wgpu::VertexFormat::Float32x2, + }, + ], + } + } +} + +#[repr(C)] +#[derive(Copy, Clone, Pod, Zeroable)] +pub struct ParticleInstanceRaw +{ + pub position: [f32; 3], + pub velocity: [f32; 3], + pub size: f32, + pub color: [f32; 4], + pub age: f32, + pub _padding: [f32; 3], +} + +const _: () = assert!(std::mem::size_of::() == 60); + +impl ParticleInstanceRaw +{ + pub fn desc() -> wgpu::VertexBufferLayout<'static> + { + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &[ + wgpu::VertexAttribute { + offset: 0, + shader_location: 2, + format: wgpu::VertexFormat::Float32x3, + }, + wgpu::VertexAttribute { + offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress, + shader_location: 3, + format: wgpu::VertexFormat::Float32x3, + }, + wgpu::VertexAttribute { + offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress * 2, + shader_location: 4, + format: wgpu::VertexFormat::Float32, + }, + wgpu::VertexAttribute { + offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress * 2 + + std::mem::size_of::() as wgpu::BufferAddress, + shader_location: 5, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress * 2 + + std::mem::size_of::() as wgpu::BufferAddress + + std::mem::size_of::<[f32; 4]>() as wgpu::BufferAddress, + shader_location: 6, + format: wgpu::VertexFormat::Float32, + }, + ], + } + } +} diff --git a/src/shaders/particle.wgsl b/src/shaders/particle.wgsl new file mode 100644 index 0000000..67baedb --- /dev/null +++ b/src/shaders/particle.wgsl @@ -0,0 +1,81 @@ +struct ParticleUniforms +{ + view_proj: mat4x4, + camera_right: vec3, + _pad0: f32, + camera_up: vec3, + _pad1: f32, +} + +@group(0) @binding(0) +var uniforms: ParticleUniforms; + +struct VertexIn +{ + @location(0) position: vec3, + @location(1) uv: vec2, +} + +struct ParticleInstance +{ + @location(2) position: vec3, + @location(3) velocity: vec3, + @location(4) size: f32, + @location(5) color: vec4, + @location(6) age: f32, +} + +struct VertexOut +{ + @builtin(position) clip_pos: vec4, + @location(0) uv: vec2, + @location(1) color: vec4, + @location(2) age: f32, +} + +@vertex +fn vs_main(in: VertexIn, inst: ParticleInstance) -> VertexOut +{ + var out: VertexOut; + + let stretch = length(inst.velocity) * 0.05; + let vel_dir = normalize(select(inst.velocity, vec3(0.0, 1.0, 0.0), + length(inst.velocity) > 0.001)); + + let world_pos = inst.position + + uniforms.camera_right * (in.uv.x - 0.5) * inst.size + + uniforms.camera_up * (in.uv.y - 0.5) * inst.size + + vel_dir * (in.uv.y - 0.5) * stretch; + + out.clip_pos = uniforms.view_proj * vec4(world_pos, 1.0); + out.uv = in.uv; + out.color = inst.color; + out.age = inst.age; + return out; +} + +var bayer: array = array( + 0.0, 8.0, 2.0, 10.0, + 12.0, 4.0, 14.0, 6.0, + 3.0, 11.0, 1.0, 9.0, + 15.0, 7.0, 13.0, 5.0, +); + +@fragment +fn fs_main(in: VertexOut) -> @location(0) vec4 +{ + let centered = in.uv * 2.0 - vec2(1.0, 1.0); + let dist = length(centered); + let circle = 1.0 - smoothstep(0.7, 1.0, dist); + let age_alpha = (1.0 - in.age) * in.color.a * circle; + + let fx = u32(in.clip_pos.x) % 4u; + let fy = u32(in.clip_pos.y) % 4u; + let thresh = bayer[fy * 4u + fx] / 16.0; + if age_alpha <= thresh + { + discard; + } + + return vec4(in.color.rgb, 1.0); +} diff --git a/src/systems/dialog_projectile.rs b/src/systems/dialog_projectile.rs index eb87080..a57c10a 100644 --- a/src/systems/dialog_projectile.rs +++ b/src/systems/dialog_projectile.rs @@ -1,8 +1,10 @@ +use std::f32::consts::PI; + use glam::Vec3; use crate::components::dialog::{DialogOutcome, DialogOutcomeEvent, ParryButton}; +use crate::components::particle::{ParticleEmitterConfig, SpawnParticleIntent}; use crate::entity::EntityHandle; - use crate::utility::input::InputState; use crate::world::World; @@ -104,6 +106,15 @@ pub fn dialog_projectile_system(world: &mut World, input_state: &InputState) world.transforms.with_mut(proj_entity, |t| { t.position += direction * PROJECTILE_SPEED * (1.0 / 60.0); }); + + let proj_pos = world.transforms.with(proj_entity, |t| t.position); + if let Some(pos) = proj_pos + { + world.spawn_particle_intents.push(SpawnParticleIntent { + origin: pos, + config: projectile_swarm_config(), + }); + } } for entity in to_despawn @@ -175,6 +186,21 @@ fn resolve_parry( None } +fn projectile_swarm_config() -> ParticleEmitterConfig +{ + ParticleEmitterConfig { + burst_count: 3, + lifetime: 0.3..0.6, + speed: 0.5..2.0, + direction: None, + direction_spread: PI, + gravity: 0.0, + size: 0.05..0.15, + color_start: [1.0, 0.3, 0.1, 1.0], + color_end: [0.2, 0.05, 0.0, 0.0], + } +} + fn is_player_evading(world: &World, player_entity: EntityHandle) -> bool { world.leaping_states.get(player_entity).is_some() diff --git a/src/systems/dialog_render.rs b/src/systems/dialog_render.rs index 21778d3..13198aa 100644 --- a/src/systems/dialog_render.rs +++ b/src/systems/dialog_render.rs @@ -9,7 +9,7 @@ const MIN_BUBBLE_WIDTH: f32 = 0.5; const TAIL_HEIGHT: f32 = 0.242; const CORNER_R: f32 = 0.18; const BORDER_W: f32 = 0.06; -const HEIGHT_OFFSET: f32 = 8.2; +const HEIGHT_OFFSET: f32 = 0.0; const FILL_COLOR: [f32; 4] = [0.05, 0.05, 0.05, 1.0]; const BORDER_COLOR: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; @@ -29,15 +29,7 @@ pub fn dialog_bubble_render_system( for bubble_entity in world.bubble_tags.all() { - let character_entity = match world - .dialog_bubbles - .with(bubble_entity, |b| b.character_entity) - { - Some(e) => e, - None => continue, - }; - - let character_pos = match world.transforms.with(character_entity, |t| t.position) + let bubble_pos = match world.transforms.with(bubble_entity, |t| t.position) { Some(p) => p, None => continue, @@ -79,8 +71,7 @@ pub fn dialog_bubble_render_system( let body_frac = body_height / bubble_height; // Billboard orientation - let body_half_h = body_height * 0.5; - let anchor = character_pos + Vec3::Y * (HEIGHT_OFFSET + body_half_h); + let anchor = bubble_pos; let to_camera = camera_pos - anchor; let forward = if to_camera.length_squared() > 1e-6 @@ -104,12 +95,11 @@ pub fn dialog_bubble_render_system( let up = forward.cross(right).normalize(); let half_w = bubble_width * 0.5; - let total_down = body_half_h + TAIL_HEIGHT; - let tl = anchor - right * half_w + up * body_half_h; - let tr = anchor + right * half_w + up * body_half_h; - let br = anchor + right * half_w - up * total_down; - let bl = anchor - right * half_w - up * total_down; + let tl = anchor - right * half_w + up * body_height; + let tr = anchor + right * half_w + up * body_height; + let br = anchor + right * half_w - up * TAIL_HEIGHT; + let bl = anchor - right * half_w - up * TAIL_HEIGHT; let vertices = [ BillboardVertex { @@ -143,7 +133,7 @@ pub fn dialog_bubble_render_system( }; let inner_half_w = bubble_width * 0.5 - border_world - TEXT_PADDING; - let inner_top_y = body_half_h - border_world - TEXT_PADDING; + let inner_top_y = body_height - border_world - TEXT_PADDING; let text_verts = atlas.build_bubble_text( &text, diff --git a/src/systems/dialog_system.rs b/src/systems/dialog_system.rs index 8d7a860..b75dc5e 100644 --- a/src/systems/dialog_system.rs +++ b/src/systems/dialog_system.rs @@ -88,7 +88,7 @@ fn spawn_bubble(world: &mut World, character_entity: EntityHandle) let bubble_entity = world.spawn(); world.transforms.insert( bubble_entity, - Transform::from_position(character_pos + Vec3::new(0.0, 2.5, 0.0)), + Transform::from_position(character_pos + Vec3::new(0.0, 8.0, 0.0)), ); world .names @@ -143,21 +143,6 @@ fn tick_displaying_bubbles(world: &mut World, delta: f32) for bubble_entity in bubbles { - let character_entity = match world.dialog_bubbles.with(bubble_entity, |b| { - if matches!(b.phase, DialogPhase::Displaying { .. }) - { - Some(b.character_entity) - } - else - { - None - } - }) - { - Some(Some(e)) => e, - _ => continue, - }; - let expired = world .dialog_bubbles .with_mut(bubble_entity, |b| { @@ -187,15 +172,15 @@ fn tick_displaying_bubbles(world: &mut World, delta: f32) } }; - let character_pos = world + let bubble_pos = world .transforms - .with(character_entity, |t| t.position) + .with(bubble_entity, |t| t.position) .unwrap_or(Vec3::ZERO); let projectile_entity = world.spawn(); world.transforms.insert( projectile_entity, - Transform::from_position(character_pos + Vec3::new(0.0, 1.5, 0.0)), + Transform::from_position(bubble_pos), ); world .names diff --git a/src/systems/mod.rs b/src/systems/mod.rs index 137540a..1c2089d 100644 --- a/src/systems/mod.rs +++ b/src/systems/mod.rs @@ -5,6 +5,7 @@ pub mod dialog_render; pub mod dialog_system; pub mod follow; pub mod input; +pub mod particle; pub mod physics_sync; pub mod render; pub mod rotate; @@ -23,6 +24,9 @@ pub use dialog_projectile::dialog_projectile_system; pub use dialog_render::dialog_bubble_render_system; pub use dialog_system::dialog_system; pub use input::player_input_system; +pub use particle::{ + collect_instances, particle_intent_system, particle_update_system, spawn_snow_particles, +}; pub use physics_sync::physics_sync_system; pub use render::render_system; pub use rotate::rotate_system; diff --git a/src/systems/particle.rs b/src/systems/particle.rs new file mode 100644 index 0000000..8db22db --- /dev/null +++ b/src/systems/particle.rs @@ -0,0 +1,196 @@ +use rand::Rng; + +use crate::components::particle::{ParticleEmitterConfig, SpawnParticleIntent}; +use crate::render::particle_types::ParticleInstanceRaw; +use crate::world::World; + +pub struct Particle +{ + pub position: glam::Vec3, + pub velocity: glam::Vec3, + pub age: f32, + pub lifetime: f32, + pub size: f32, + pub gravity: f32, + pub color_start: [f32; 4], + pub color_end: [f32; 4], +} + +pub struct ParticleBuffers +{ + pub particles: Vec, + pub instances: Vec, + pub emit_accumulator: f32, +} + +fn random_unit_vec(rng: &mut impl Rng) -> glam::Vec3 +{ + loop + { + let v = glam::Vec3::new( + rng.random_range(-1.0_f32..1.0), + rng.random_range(-1.0_f32..1.0), + rng.random_range(-1.0_f32..1.0), + ); + let len_sq = v.length_squared(); + if len_sq <= 1.0 && len_sq > 0.0001 + { + return v / len_sq.sqrt(); + } + } +} + +fn random_velocity(rng: &mut impl Rng, config: &ParticleEmitterConfig) -> glam::Vec3 +{ + let speed = rng.random_range(config.speed.start..config.speed.end); + let dir = if let Some(d) = config.direction + { + let perp = if d.x.abs() < 0.9 + { + glam::Vec3::X + } + else + { + glam::Vec3::Y + }; + let right = d.cross(perp).normalize(); + let up = d.cross(right); + let angle = rng.random_range(0.0_f32..config.direction_spread); + let azimuth = rng.random_range(0.0_f32..(2.0 * std::f32::consts::PI)); + (d * angle.cos() + right * angle.sin() * azimuth.cos() + up * angle.sin() * azimuth.sin()) + .normalize() + } + else + { + random_unit_vec(rng) + }; + dir * speed +} + +fn spawn_from_config( + buffers: &mut ParticleBuffers, + origin: glam::Vec3, + config: &ParticleEmitterConfig, + rng: &mut impl Rng, +) +{ + for _ in 0..config.burst_count + { + let lifetime = rng.random_range(config.lifetime.start..config.lifetime.end); + let size = rng.random_range(config.size.start..config.size.end); + let velocity = random_velocity(rng, config); + buffers.particles.push(Particle { + position: origin, + velocity, + age: 0.0, + lifetime, + size, + gravity: config.gravity, + color_start: config.color_start, + color_end: config.color_end, + }); + } +} + +pub fn particle_intent_system(world: &mut World) +{ + if world.particle_buffers.is_none() + { + world.particle_buffers = Some(ParticleBuffers { + particles: Vec::new(), + instances: Vec::new(), + emit_accumulator: 0.0, + }); + } + + let intents: Vec = world.spawn_particle_intents.drain(..).collect(); + if intents.is_empty() + { + return; + } + + let buffers = world.particle_buffers.as_mut().unwrap(); + let mut rng = rand::rng(); + for intent in intents + { + spawn_from_config(buffers, intent.origin, &intent.config, &mut rng); + } +} + +pub fn particle_update_system(world: &mut World, delta: f32) +{ + let Some(ref mut buffers) = world.particle_buffers + else + { + return; + }; + + for particle in &mut buffers.particles + { + particle.velocity.y -= particle.gravity * delta; + particle.position += particle.velocity * delta; + particle.age += delta / particle.lifetime; + } + + buffers.particles.retain(|p| p.age < 1.0); +} + +pub fn spawn_snow_particles(buffers: &mut ParticleBuffers, camera_pos: glam::Vec3, delta: f32) +{ + let mut rng = rand::rng(); + + let rate = 200.0_f32; + buffers.emit_accumulator += delta; + let to_emit = (buffers.emit_accumulator * rate) as u32; + buffers.emit_accumulator -= to_emit as f32 / rate; + + for _ in 0..to_emit + { + let x = camera_pos.x + rng.random_range(-20.0_f32..20.0_f32); + let z = camera_pos.z + rng.random_range(-20.0_f32..20.0_f32); + let y = rng.random_range((camera_pos.y + 8.0)..(camera_pos.y + 20.0)); + let vx = rng.random_range(-0.3_f32..0.3_f32); + let vy = rng.random_range(-2.0_f32..-0.5_f32); + let vz = rng.random_range(-0.3_f32..0.3_f32); + let size = rng.random_range(0.05_f32..0.15_f32); + let lifetime = rng.random_range(2.0_f32..5.0_f32); + buffers.particles.push(Particle { + position: glam::Vec3::new(x, y, z), + velocity: glam::Vec3::new(vx, vy, vz), + age: 0.0, + lifetime, + size, + gravity: 0.0, + color_start: [1.0, 1.0, 1.0, 1.0], + color_end: [1.0, 1.0, 1.0, 0.0], + }); + } + + buffers + .particles + .retain(|p| p.position.y >= camera_pos.y - 5.0); +} + +pub fn collect_instances(buffers: &mut ParticleBuffers) -> &[ParticleInstanceRaw] +{ + buffers.instances.clear(); + for particle in &buffers.particles + { + let t = particle.age; + let color = [ + particle.color_start[0] + (particle.color_end[0] - particle.color_start[0]) * t, + particle.color_start[1] + (particle.color_end[1] - particle.color_start[1]) * t, + particle.color_start[2] + (particle.color_end[2] - particle.color_start[2]) * t, + particle.color_start[3] + (particle.color_end[3] - particle.color_start[3]) * t, + ]; + buffers.instances.push(ParticleInstanceRaw { + position: particle.position.into(), + velocity: particle.velocity.into(), + size: particle.size, + color, + age: t, + _padding: [0.0; 3], + }); + } + &buffers.instances +} diff --git a/src/world.rs b/src/world.rs index c2abbdf..2afb9f4 100644 --- a/src/world.rs +++ b/src/world.rs @@ -7,6 +7,7 @@ use crate::components::dissolve::DissolveComponent; use crate::components::follow::FollowComponent; use crate::components::intent::{CameraTransitionIntent, FollowPlayerIntent, StopFollowingIntent}; use crate::components::lights::spot::SpotlightComponent; +use crate::components::particle::SpawnParticleIntent; use crate::components::player_states::{ FallingState, IdleState, JumpingState, LeapingState, RollingState, WalkingState, }; @@ -20,6 +21,7 @@ use crate::debug::DebugMode; use crate::entity::{EntityHandle, EntityManager}; use crate::render::snow::SnowLayer; use crate::states::state::StateMachine; +use crate::systems::particle::ParticleBuffers; pub use crate::utility::transform::Transform; @@ -111,6 +113,8 @@ pub struct World pub bubble_tags: Storage<()>, pub projectile_tags: Storage<()>, pub dialog_outcomes: Vec, + pub spawn_particle_intents: Vec, + pub particle_buffers: Option, // --- intents (one-frame, consumed after processing) --- pub follow_player_intents: Storage, @@ -160,6 +164,8 @@ impl World bubble_tags: Storage::new(), projectile_tags: Storage::new(), dialog_outcomes: Vec::new(), + spawn_particle_intents: Vec::new(), + particle_buffers: None, follow_player_intents: Storage::new(), stop_following_intents: Storage::new(), camera_transition_intents: Storage::new(),