Compare commits

...

6 Commits

Author SHA1 Message Date
Jonas H
87177ad97d intent based architecture 2026-04-02 20:01:07 +02:00
Jonas H
b0022ad17b gizmo shader 2026-04-02 19:57:31 +02:00
Jonas H
d37a3c87e3 terrain test char pos 2026-04-02 19:57:09 +02:00
Jonas H
16626cc277 gizmo 2026-04-02 19:56:01 +02:00
Jonas H
2846c04765 velocity for projectiles 2026-04-02 19:55:50 +02:00
Jonas H
dffd731b87 particles 2026-04-02 16:54:23 +02:00
28 changed files with 1099 additions and 130 deletions

View File

@@ -0,0 +1,15 @@
---
name: intent-based-architecture
description: Intent-based architecture patterns for the snow_trail_sdl game engine. Load when adding cross-system communication, new systems to the main loop, changing game mode logic (editor/dialog/gameplay), or working with camera, input, or mode-switching. Systems communicate through typed intent queues — no direct cross-system function calls.
---
# Intent-Based Architecture Skill
Read the architecture document at `docs/intent-based-architecture.md` (resolve relative to the project root `/home/jonas/projects/snow_trail_sdl/`) and follow its patterns when:
- Adding cross-system communication (use intents, not direct function calls)
- Adding new systems to the main loop
- Changing how game modes work (editor, dialog, gameplay)
- Working with camera, input, or mode-switching logic
Key rule: **systems communicate through typed intent data in shared queues** — producers insert intents, consumers process and remove them. No system calls another system's functions directly.

View File

@@ -1,10 +0,0 @@
# Intent-Based Architecture Skill
Read the architecture document at `docs/self-gating-systems.md` (resolve relative to the project root `/home/jonas/projects/snow_trail_sdl/`) and follow its patterns when:
- Adding cross-system communication (use intents, not direct function calls)
- Adding new systems to the main loop
- Changing how game modes work (editor, dialog, gameplay)
- Working with camera, input, or mode-switching logic
Key rule: **systems communicate through typed intent data in shared queues** — producers insert intents, consumers process and remove them. No system calls another system's functions directly.

View File

@@ -24,6 +24,7 @@ serde_json = "1.0"
bladeink = "1.2" bladeink = "1.2"
wesl = "0.2" wesl = "0.2"
ab_glyph = "0.2" ab_glyph = "0.2"
rand = "0.9"
[build-dependencies] [build-dependencies]
wesl = "0.2" wesl = "0.2"

View File

@@ -144,9 +144,9 @@
{ {
"name":"TestCharSpawn", "name":"TestCharSpawn",
"translation":[ "translation":[
-381.1509704589844, -376.7967224121094,
106.53739166259766, 115.31475830078125,
107.46959686279297 155.05471801757812
] ]
}, },
{ {

Binary file not shown.

Binary file not shown.

View File

@@ -1,4 +1,5 @@
use bladeink::story::Story; use bladeink::story::Story;
use glam::Vec3;
use crate::entity::EntityHandle; use crate::entity::EntityHandle;
@@ -56,6 +57,7 @@ pub struct DialogProjectileComponent
pub bubble_entity: EntityHandle, pub bubble_entity: EntityHandle,
pub correct_parry: ParryButton, pub correct_parry: ParryButton,
pub parry_window_open: bool, pub parry_window_open: bool,
pub velocity: Vec3,
} }
#[derive(Clone, Copy, PartialEq, Eq, Debug)] #[derive(Clone, Copy, PartialEq, Eq, Debug)]

View File

@@ -9,6 +9,7 @@ pub mod lights;
pub mod mesh; pub mod mesh;
pub mod movement; pub mod movement;
pub mod noclip; pub mod noclip;
pub mod particle;
pub mod physics; pub mod physics;
pub mod player_states; pub mod player_states;
pub mod rotate; pub mod rotate;
@@ -26,6 +27,7 @@ pub use input::InputComponent;
pub use jump::JumpComponent; pub use jump::JumpComponent;
pub use mesh::MeshComponent; pub use mesh::MeshComponent;
pub use movement::MovementComponent; pub use movement::MovementComponent;
pub use particle::{ParticleEmitterConfig, SpawnParticleIntent};
pub use physics::PhysicsComponent; pub use physics::PhysicsComponent;
pub use rotate::RotateComponent; pub use rotate::RotateComponent;
pub use tree_instances::TreeInstancesComponent; pub use tree_instances::TreeInstancesComponent;

View File

@@ -0,0 +1,18 @@
pub struct ParticleEmitterConfig
{
pub burst_count: u32,
pub lifetime: std::ops::Range<f32>,
pub speed: std::ops::Range<f32>,
pub direction: Option<glam::Vec3>,
pub direction_spread: f32,
pub gravity: f32,
pub size: std::ops::Range<f32>,
pub color_start: [f32; 4],
pub color_end: [f32; 4],
}
pub struct SpawnParticleIntent
{
pub origin: glam::Vec3,
pub config: ParticleEmitterConfig,
}

178
src/debug/gizmo.rs Normal file
View File

@@ -0,0 +1,178 @@
use bytemuck::cast_slice;
use glam::{Mat4, Vec3};
use wgpu::util::DeviceExt;
use crate::entity::EntityHandle;
use crate::loaders::mesh::{InstanceRaw, Mesh, Vertex};
use crate::render::{self, DrawCall, Pipeline};
use crate::utility::transform::Transform;
use crate::world::Storage;
const GIZMO_DISTANCE_SCALE: f32 = 0.1;
const ARROW_BASE: f32 = 0.85;
const ARROW_SPREAD: f32 = 0.08;
pub fn create_gizmo_mesh(device: &wgpu::Device) -> Mesh
{
let vertices = vec![
Vertex {
position: [0.0, 0.0, 0.0],
normal: [1.0, 0.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [1.0, 0.0, 0.0],
normal: [1.0, 0.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [0.0, 0.0, 0.0],
normal: [0.0, 1.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [0.0, 1.0, 0.0],
normal: [0.0, 1.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [0.0, 0.0, 0.0],
normal: [0.0, 0.0, 1.0],
uv: [0.0, 0.0],
},
Vertex {
position: [0.0, 0.0, 1.0],
normal: [0.0, 0.0, 1.0],
uv: [0.0, 0.0],
},
Vertex {
position: [ARROW_BASE, ARROW_SPREAD, 0.0],
normal: [1.0, 0.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [ARROW_BASE, -ARROW_SPREAD, 0.0],
normal: [1.0, 0.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [ARROW_BASE, 0.0, ARROW_SPREAD],
normal: [1.0, 0.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [ARROW_BASE, 0.0, -ARROW_SPREAD],
normal: [1.0, 0.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [ARROW_SPREAD, ARROW_BASE, 0.0],
normal: [0.0, 1.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [-ARROW_SPREAD, ARROW_BASE, 0.0],
normal: [0.0, 1.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [0.0, ARROW_BASE, ARROW_SPREAD],
normal: [0.0, 1.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [0.0, ARROW_BASE, -ARROW_SPREAD],
normal: [0.0, 1.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [ARROW_SPREAD, 0.0, ARROW_BASE],
normal: [0.0, 0.0, 1.0],
uv: [0.0, 0.0],
},
Vertex {
position: [-ARROW_SPREAD, 0.0, ARROW_BASE],
normal: [0.0, 0.0, 1.0],
uv: [0.0, 0.0],
},
Vertex {
position: [0.0, ARROW_SPREAD, ARROW_BASE],
normal: [0.0, 0.0, 1.0],
uv: [0.0, 0.0],
},
Vertex {
position: [0.0, -ARROW_SPREAD, ARROW_BASE],
normal: [0.0, 0.0, 1.0],
uv: [0.0, 0.0],
},
];
let indices = vec![
0, 1, 2, 3, 4, 5, 6, 1, 7, 1, 8, 1, 9, 1, 10, 3, 11, 3, 12, 3, 13, 3, 14, 5, 15, 5, 16, 5,
17, 5,
];
Mesh::new(device, &vertices, &indices)
}
pub fn create_gizmo_instance_buffer(device: &wgpu::Device) -> wgpu::Buffer
{
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Gizmo Instance Buffer"),
contents: cast_slice(&[InstanceRaw {
model: Mat4::IDENTITY.to_cols_array_2d(),
dissolve_amount: 0.0,
_padding: [0.0; 3],
}]),
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
})
}
pub fn render_transform_gizmo(
entity: EntityHandle,
transforms: &Storage<Transform>,
camera_position: Vec3,
gizmo_mesh: &Mesh,
instance_buffer: &wgpu::Buffer,
) -> Vec<DrawCall>
{
let transform = match transforms.get(entity)
{
Some(t) => t,
None => return Vec::new(),
};
let distance = camera_position.distance(transform.position);
let scale = (distance * GIZMO_DISTANCE_SCALE).max(0.1);
let model = Mat4::from_scale_rotation_translation(
Vec3::splat(scale),
transform.rotation,
transform.position,
);
let instance_data = InstanceRaw {
model: model.to_cols_array_2d(),
dissolve_amount: 0.0,
_padding: [0.0; 3],
};
render::with_queue(|queue| {
queue.write_buffer(instance_buffer, 0, cast_slice(&[instance_data]));
});
vec![DrawCall {
vertex_buffer: gizmo_mesh.vertex_buffer.clone(),
index_buffer: gizmo_mesh.index_buffer.clone(),
num_indices: gizmo_mesh.num_indices,
model,
pipeline: Pipeline::GizmoLines,
instance_buffer: Some(instance_buffer.clone()),
num_instances: 1,
tile_scale: 4.0,
enable_dissolve: false,
enable_snow_light: false,
displacement_bind_group: None,
entity: None,
}]
}

View File

@@ -1,4 +1,5 @@
pub mod collider_debug; pub mod collider_debug;
pub mod gizmo;
pub mod mode; pub mod mode;
pub use mode::DebugMode; pub use mode::DebugMode;

View File

@@ -22,18 +22,20 @@ use crate::bundles::terrain::{TerrainBundle, TerrainConfig};
use crate::bundles::test_char::TestCharBundle; use crate::bundles::test_char::TestCharBundle;
use crate::bundles::Bundle; use crate::bundles::Bundle;
use crate::components::intent::{FollowPlayerIntent, StopFollowingIntent}; use crate::components::intent::{FollowPlayerIntent, StopFollowingIntent};
use crate::debug::{collider_debug, DebugMode}; use crate::debug::{collider_debug, gizmo, DebugMode};
use crate::editor::{editor_loop, EditorState, FrameStats}; use crate::editor::{editor_loop, EditorState, FrameStats};
use crate::entity::EntityHandle; use crate::entity::EntityHandle;
use crate::loaders::scene::Space; use crate::loaders::scene::Space;
use crate::physics::PhysicsManager; use crate::physics::PhysicsManager;
use crate::render::particle_types::ParticleInstanceRaw;
use crate::render::snow::{SnowConfig, SnowLayer}; use crate::render::snow::{SnowConfig, SnowLayer};
use crate::systems::{ use crate::systems::{
camera_follow_system, camera_ground_clamp_system, camera_input_system, camera_intent_system, 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_bubble_render_system, dialog_camera_system, dialog_camera_transition_system,
dialog_projectile_system, dialog_system, physics_sync_system, player_input_system, dialog_projectile_system, dialog_system, particle_intent_system, particle_update_system,
render_system, rotate_system, snow_system, spotlight_sync_system, state_machine_physics_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, state_machine_system, tree_dissolve_update_system, tree_instance_buffer_update_system,
tree_occlusion_system, trigger_system, tree_occlusion_system, trigger_system,
}; };
@@ -279,6 +281,15 @@ fn toggle_editor(game: &mut Game)
.set_relative_mouse_mode(&game.window, false); .set_relative_mouse_mode(&game.window, false);
game.editor.right_mouse_held = false; game.editor.right_mouse_held = false;
game.input_state.mouse_captured = false; game.input_state.mouse_captured = false;
if game.world.gizmo_mesh.is_none()
{
game.world.gizmo_mesh = Some(render::with_device(|device| {
gizmo::create_gizmo_mesh(device)
}));
game.world.gizmo_instance_buffer = Some(render::with_device(|device| {
gizmo::create_gizmo_instance_buffer(device)
}));
}
} }
else else
{ {
@@ -311,7 +322,13 @@ fn handle_editor_pick(game: &mut Game, x: f32, y: f32)
render::set_selected_entity(game.editor.selected_entity); 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() let (camera_entity, camera_component) = match game.world.active_camera()
{ {
@@ -344,6 +361,7 @@ fn submit_frame(game: &mut Game, draw_calls: &[render::DrawCall], time: f32, del
draw_calls, draw_calls,
&billboard_calls, &billboard_calls,
&text_vertices, &text_vertices,
particle_instances,
time, time,
delta, delta,
game.world.debug_mode, game.world.debug_mode,
@@ -448,6 +466,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>>
snow_system(&mut game.world); 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 --- // --- draw call collection ---
let mut draw_calls = render_system(&game.world); let mut draw_calls = render_system(&game.world);
if let Some(ref snow_layer) = game.world.snow_layer if let Some(ref snow_layer) = game.world.snow_layer
@@ -460,12 +486,38 @@ fn main() -> Result<(), Box<dyn std::error::Error>>
draw_calls.extend(collider_debug::render_collider_debug()); draw_calls.extend(collider_debug::render_collider_debug());
} }
if game.editor.active
{
if let Some(entity) = game.editor.selected_entity
{
let cam_pos = game.world.active_camera_position();
if let (Some(ref mesh), Some(ref buf)) =
(&game.world.gizmo_mesh, &game.world.gizmo_instance_buffer)
{
draw_calls.extend(gizmo::render_transform_gizmo(
entity,
&game.world.transforms,
cam_pos,
mesh,
buf,
));
}
}
}
game.stats.draw_call_count = draw_calls.len(); game.stats.draw_call_count = draw_calls.len();
game.stats.fps = 1.0 / delta; game.stats.fps = 1.0 / delta;
game.stats.frame_ms = delta * 1000.0; game.stats.frame_ms = delta * 1000.0;
let particle_instances: Vec<ParticleInstanceRaw> = game
.world
.particle_buffers
.as_mut()
.map(|b| collect_instances(b).to_vec())
.unwrap_or_default();
// --- render --- // --- render ---
submit_frame(&mut game, &draw_calls, time, delta); submit_frame(&mut game, &draw_calls, &particle_instances, time, delta);
// --- end frame --- // --- end frame ---
game.input_state.clear_just_pressed(); game.input_state.clear_just_pressed();

View File

@@ -102,6 +102,11 @@ pub mod shaders
format!("{}/text.wgsl", SHADERS_DIR) format!("{}/text.wgsl", SHADERS_DIR)
} }
pub fn particle() -> String
{
format!("{}/particle.wgsl", SHADERS_DIR)
}
pub const SHADOW_PACKAGE: &str = "package::shadow"; pub const SHADOW_PACKAGE: &str = "package::shadow";
pub const MAIN_PACKAGE: &str = "package::main"; pub const MAIN_PACKAGE: &str = "package::main";
pub const SNOW_LIGHT_ACCUMULATION_PACKAGE: &str = "package::snow_light_accumulation"; pub const SNOW_LIGHT_ACCUMULATION_PACKAGE: &str = "package::snow_light_accumulation";

View File

@@ -9,6 +9,7 @@ use std::cell::RefCell;
use crate::debug::DebugMode; use crate::debug::DebugMode;
use crate::entity::EntityHandle; use crate::entity::EntityHandle;
use super::particle_types::ParticleInstanceRaw;
use super::text_pipeline::TextVertex; use super::text_pipeline::TextVertex;
use super::{BillboardDrawCall, DrawCall, FontAtlas, Renderer, Spotlight}; use super::{BillboardDrawCall, DrawCall, FontAtlas, Renderer, Spotlight};
@@ -115,6 +116,7 @@ pub fn render(
draw_calls: &[DrawCall], draw_calls: &[DrawCall],
billboard_calls: &[BillboardDrawCall], billboard_calls: &[BillboardDrawCall],
text_vertices: &[TextVertex], text_vertices: &[TextVertex],
particle_instances: &[ParticleInstanceRaw],
time: f32, time: f32,
delta_time: f32, delta_time: f32,
debug_mode: DebugMode, debug_mode: DebugMode,
@@ -129,6 +131,7 @@ pub fn render(
draw_calls, draw_calls,
billboard_calls, billboard_calls,
text_vertices, text_vertices,
particle_instances,
time, time,
delta_time, delta_time,
debug_mode, debug_mode,

View File

@@ -7,6 +7,8 @@ mod types;
pub mod billboard; pub mod billboard;
pub mod font_atlas; pub mod font_atlas;
pub mod particle_pipeline;
pub mod particle_types;
pub mod snow; pub mod snow;
pub mod snow_light; pub mod snow_light;
pub mod text_pipeline; pub mod text_pipeline;
@@ -18,6 +20,8 @@ pub use global::{
set_terrain_data, update_spotlights, with_device, with_font_atlas, with_queue, set_terrain_data, update_spotlights, with_device, with_font_atlas, with_queue,
with_surface_format, with_surface_format,
}; };
pub use particle_pipeline::ParticlePipeline;
pub use particle_types::ParticleInstanceRaw;
pub use text_pipeline::TextVertex; pub use text_pipeline::TextVertex;
pub use types::{DrawCall, Pipeline, Spotlight, SpotlightRaw, Uniforms, MAX_SPOTLIGHTS}; pub use types::{DrawCall, Pipeline, Spotlight, SpotlightRaw, Uniforms, MAX_SPOTLIGHTS};
@@ -28,8 +32,8 @@ use crate::paths;
use crate::postprocess::{create_blit_pipeline, create_fullscreen_quad, LowResFramebuffer}; use crate::postprocess::{create_blit_pipeline, create_fullscreen_quad, LowResFramebuffer};
use crate::texture::{DitherTextures, FlowmapTexture}; use crate::texture::{DitherTextures, FlowmapTexture};
use pipeline::{ use pipeline::{
create_debug_lines_pipeline, create_main_pipeline, create_snow_clipmap_pipeline, create_debug_lines_pipeline, create_gizmo_lines_pipeline, create_main_pipeline,
create_wireframe_pipeline, create_snow_clipmap_pipeline, create_wireframe_pipeline,
}; };
use std::num::NonZeroU64; use std::num::NonZeroU64;
@@ -49,8 +53,10 @@ pub struct Renderer
snow_clipmap_pipeline: wgpu::RenderPipeline, snow_clipmap_pipeline: wgpu::RenderPipeline,
wireframe_pipeline: Option<wgpu::RenderPipeline>, wireframe_pipeline: Option<wgpu::RenderPipeline>,
debug_lines_pipeline: Option<wgpu::RenderPipeline>, debug_lines_pipeline: Option<wgpu::RenderPipeline>,
gizmo_lines_pipeline: Option<wgpu::RenderPipeline>,
debug_overlay: Option<debug_overlay::DebugOverlay>, debug_overlay: Option<debug_overlay::DebugOverlay>,
billboard_pipeline: BillboardPipeline, billboard_pipeline: BillboardPipeline,
particle_pipeline: ParticlePipeline,
font_atlas: font_atlas::FontAtlas, font_atlas: font_atlas::FontAtlas,
text_pipeline: text_pipeline::TextPipeline, text_pipeline: text_pipeline::TextPipeline,
wireframe_supported: bool, wireframe_supported: bool,
@@ -526,7 +532,14 @@ impl Renderer
&bind_group_layout, &bind_group_layout,
)); ));
let gizmo_lines_pipeline = Some(create_gizmo_lines_pipeline(
&device,
config.format,
&bind_group_layout,
));
let billboard_pipeline = BillboardPipeline::new(&device, config.format); 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 font_atlas = font_atlas::FontAtlas::load(&device, &queue);
let text_pipeline = text_pipeline::TextPipeline::new(&device, config.format, &font_atlas); let text_pipeline = text_pipeline::TextPipeline::new(&device, config.format, &font_atlas);
@@ -558,8 +571,10 @@ impl Renderer
snow_clipmap_pipeline, snow_clipmap_pipeline,
wireframe_pipeline, wireframe_pipeline,
debug_lines_pipeline, debug_lines_pipeline,
gizmo_lines_pipeline,
debug_overlay, debug_overlay,
billboard_pipeline, billboard_pipeline,
particle_pipeline,
font_atlas, font_atlas,
text_pipeline, text_pipeline,
wireframe_supported, wireframe_supported,
@@ -607,6 +622,7 @@ impl Renderer
draw_calls: &[DrawCall], draw_calls: &[DrawCall],
billboard_calls: &[BillboardDrawCall], billboard_calls: &[BillboardDrawCall],
text_vertices: &[text_pipeline::TextVertex], text_vertices: &[text_pipeline::TextVertex],
particle_instances: &[ParticleInstanceRaw],
time: f32, time: f32,
delta_time: f32, delta_time: f32,
debug_mode: DebugMode, debug_mode: DebugMode,
@@ -732,6 +748,10 @@ impl Renderer
.debug_lines_pipeline .debug_lines_pipeline
.as_ref() .as_ref()
.unwrap_or(&self.standard_pipeline), .unwrap_or(&self.standard_pipeline),
Pipeline::GizmoLines => self
.gizmo_lines_pipeline
.as_ref()
.unwrap_or(&self.standard_pipeline),
}; };
render_pass.set_pipeline(pipeline); render_pass.set_pipeline(pipeline);
render_pass.set_bind_group(0, &self.bind_group, &[offset as u32]); render_pass.set_bind_group(0, &self.bind_group, &[offset as u32]);
@@ -762,7 +782,7 @@ impl Renderer
{ {
if matches!( if matches!(
draw_call.pipeline, draw_call.pipeline,
Pipeline::SnowClipmap | Pipeline::DebugLines Pipeline::SnowClipmap | Pipeline::DebugLines | Pipeline::GizmoLines
) )
{ {
continue; continue;
@@ -856,7 +876,7 @@ impl Renderer
if matches!( if matches!(
draw_call.pipeline, draw_call.pipeline,
Pipeline::SnowClipmap | Pipeline::DebugLines Pipeline::SnowClipmap | Pipeline::DebugLines | Pipeline::GizmoLines
) )
{ {
continue; continue;
@@ -1068,16 +1088,29 @@ impl Renderer
billboard_calls, billboard_calls,
); );
let view_proj = (*projection * *view).to_cols_array_2d(); let view_proj_mat = *projection * *view;
self.text_pipeline.render( self.text_pipeline.render(
&mut overlay_encoder, &mut overlay_encoder,
&self.queue, &self.queue,
&screen_view, &screen_view,
&self.fullres_depth_view, &self.fullres_depth_view,
text_vertices, 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())); self.queue.submit(std::iter::once(overlay_encoder.finish()));
frame frame
} }

View File

@@ -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::<ParticleUniforms>() 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::<ParticleInstanceRaw>()) 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);
}
}
}

View File

@@ -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::<ParticleVertex>() 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::<ParticleInstanceRaw>() == 60);
impl ParticleInstanceRaw
{
pub fn desc() -> wgpu::VertexBufferLayout<'static>
{
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<ParticleInstanceRaw>() 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::<f32>() 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::<f32>() as wgpu::BufferAddress
+ std::mem::size_of::<[f32; 4]>() as wgpu::BufferAddress,
shader_location: 6,
format: wgpu::VertexFormat::Float32,
},
],
}
}
}

View File

@@ -1,6 +1,92 @@
use crate::paths; use crate::paths;
use wesl::Wesl; use wesl::Wesl;
fn create_lines_pipeline(
device: &wgpu::Device,
format: wgpu::TextureFormat,
bind_group_layout: &wgpu::BindGroupLayout,
fragment_entry_point: &str,
label_prefix: &str,
) -> wgpu::RenderPipeline
{
let compiler = Wesl::new(&paths::SHADERS_DIR);
let shader_source = compiler
.compile(&paths::shaders::MAIN_PACKAGE.parse().unwrap())
.inspect_err(|e| eprintln!("WESL error: {e}"))
.unwrap()
.to_string();
let shader_label = format!("{label_prefix} Shader");
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&shader_label),
source: wgpu::ShaderSource::Wgsl(shader_source.into()),
});
let layout_label = format!("{label_prefix} Pipeline Layout");
let render_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(&layout_label),
bind_group_layouts: &[bind_group_layout],
immediate_size: 0,
});
let pipeline_label = format!("{label_prefix} Pipeline");
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&pipeline_label),
layout: Some(&render_pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[
crate::loaders::mesh::Vertex::desc(),
crate::loaders::mesh::InstanceRaw::desc(),
],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(fragment_entry_point),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::LineList,
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::Always,
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,
})
}
pub fn create_gizmo_lines_pipeline(
device: &wgpu::Device,
format: wgpu::TextureFormat,
bind_group_layout: &wgpu::BindGroupLayout,
) -> wgpu::RenderPipeline
{
create_lines_pipeline(device, format, bind_group_layout, "fs_gizmo", "Gizmo Lines")
}
pub fn create_shadow_pipeline( pub fn create_shadow_pipeline(
device: &wgpu::Device, device: &wgpu::Device,
bind_group_layout: &wgpu::BindGroupLayout, bind_group_layout: &wgpu::BindGroupLayout,
@@ -215,70 +301,7 @@ pub fn create_debug_lines_pipeline(
bind_group_layout: &wgpu::BindGroupLayout, bind_group_layout: &wgpu::BindGroupLayout,
) -> wgpu::RenderPipeline ) -> wgpu::RenderPipeline
{ {
let compiler = Wesl::new(&paths::SHADERS_DIR); create_lines_pipeline(device, format, bind_group_layout, "fs_main", "Debug Lines")
let shader_source = compiler
.compile(&paths::shaders::MAIN_PACKAGE.parse().unwrap())
.inspect_err(|e| eprintln!("WESL error: {e}"))
.unwrap()
.to_string();
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Debug Lines Shader"),
source: wgpu::ShaderSource::Wgsl(shader_source.into()),
});
let render_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Debug Lines Pipeline Layout"),
bind_group_layouts: &[bind_group_layout],
immediate_size: 0,
});
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Debug Lines Pipeline"),
layout: Some(&render_pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[
crate::loaders::mesh::Vertex::desc(),
crate::loaders::mesh::InstanceRaw::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::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::LineList,
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::Always,
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,
})
} }
pub fn create_snow_clipmap_pipeline( pub fn create_snow_clipmap_pipeline(

View File

@@ -143,6 +143,7 @@ pub enum Pipeline
Standard, Standard,
SnowClipmap, SnowClipmap,
DebugLines, DebugLines,
GizmoLines,
} }
pub struct DrawCall pub struct DrawCall

View File

@@ -81,6 +81,15 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
} }
} }
/// Fragment shader for transform gizmo lines.
/// Outputs `world_normal` directly as the RGB color — the vertex data encodes
/// axis color (X=red, Y=green, Z=blue) in the normal attribute rather than
/// storing a true geometric normal.
@fragment
fn fs_gizmo(in: VertexOutput) -> @location(0) vec4<f32> {
return vec4(in.world_normal, 1.0);
}
@group(1) @binding(0) @group(1) @binding(0)
var heightmap_texture: texture_2d<f32>; var heightmap_texture: texture_2d<f32>;

81
src/shaders/particle.wgsl Normal file
View File

@@ -0,0 +1,81 @@
struct ParticleUniforms
{
view_proj: mat4x4<f32>,
camera_right: vec3<f32>,
_pad0: f32,
camera_up: vec3<f32>,
_pad1: f32,
}
@group(0) @binding(0)
var<uniform> uniforms: ParticleUniforms;
struct VertexIn
{
@location(0) position: vec3<f32>,
@location(1) uv: vec2<f32>,
}
struct ParticleInstance
{
@location(2) position: vec3<f32>,
@location(3) velocity: vec3<f32>,
@location(4) size: f32,
@location(5) color: vec4<f32>,
@location(6) age: f32,
}
struct VertexOut
{
@builtin(position) clip_pos: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
@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<f32>(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<f32>(world_pos, 1.0);
out.uv = in.uv;
out.color = inst.color;
out.age = inst.age;
return out;
}
var<private> bayer: array<f32, 16> = array<f32, 16>(
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<f32>
{
let centered = in.uv * 2.0 - vec2<f32>(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<f32>(in.color.rgb, 1.0);
}

View File

@@ -1,8 +1,10 @@
use std::f32::consts::PI;
use glam::Vec3; use glam::Vec3;
use crate::components::dialog::{DialogOutcome, DialogOutcomeEvent, ParryButton}; use crate::components::dialog::{DialogOutcome, DialogOutcomeEvent, ParryButton};
use crate::components::particle::{ParticleEmitterConfig, SpawnParticleIntent};
use crate::entity::EntityHandle; use crate::entity::EntityHandle;
use crate::utility::input::InputState; use crate::utility::input::InputState;
use crate::world::World; 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| { world.transforms.with_mut(proj_entity, |t| {
t.position += direction * PROJECTILE_SPEED * (1.0 / 60.0); 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 for entity in to_despawn
@@ -175,6 +186,21 @@ fn resolve_parry(
None 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 fn is_player_evading(world: &World, player_entity: EntityHandle) -> bool
{ {
world.leaping_states.get(player_entity).is_some() world.leaping_states.get(player_entity).is_some()

View File

@@ -9,7 +9,7 @@ const MIN_BUBBLE_WIDTH: f32 = 0.5;
const TAIL_HEIGHT: f32 = 0.242; const TAIL_HEIGHT: f32 = 0.242;
const CORNER_R: f32 = 0.18; const CORNER_R: f32 = 0.18;
const BORDER_W: f32 = 0.06; 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 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]; 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() for bubble_entity in world.bubble_tags.all()
{ {
let character_entity = match world let bubble_pos = match world.transforms.with(bubble_entity, |t| t.position)
.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)
{ {
Some(p) => p, Some(p) => p,
None => continue, None => continue,
@@ -79,8 +71,7 @@ pub fn dialog_bubble_render_system(
let body_frac = body_height / bubble_height; let body_frac = body_height / bubble_height;
// Billboard orientation // Billboard orientation
let body_half_h = body_height * 0.5; let anchor = bubble_pos;
let anchor = character_pos + Vec3::Y * (HEIGHT_OFFSET + body_half_h);
let to_camera = camera_pos - anchor; let to_camera = camera_pos - anchor;
let forward = if to_camera.length_squared() > 1e-6 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 up = forward.cross(right).normalize();
let half_w = bubble_width * 0.5; 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 tl = anchor - right * half_w + up * body_height;
let tr = anchor + right * half_w + up * body_half_h; let tr = anchor + right * half_w + up * body_height;
let br = anchor + right * half_w - up * total_down; let br = anchor + right * half_w - up * TAIL_HEIGHT;
let bl = anchor - right * half_w - up * total_down; let bl = anchor - right * half_w - up * TAIL_HEIGHT;
let vertices = [ let vertices = [
BillboardVertex { 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_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( let text_verts = atlas.build_bubble_text(
&text, &text,

View File

@@ -88,7 +88,7 @@ fn spawn_bubble(world: &mut World, character_entity: EntityHandle)
let bubble_entity = world.spawn(); let bubble_entity = world.spawn();
world.transforms.insert( world.transforms.insert(
bubble_entity, 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 world
.names .names
@@ -143,21 +143,6 @@ fn tick_displaying_bubbles(world: &mut World, delta: f32)
for bubble_entity in bubbles 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 let expired = world
.dialog_bubbles .dialog_bubbles
.with_mut(bubble_entity, |b| { .with_mut(bubble_entity, |b| {
@@ -187,16 +172,28 @@ fn tick_displaying_bubbles(world: &mut World, delta: f32)
} }
}; };
let character_pos = world let bubble_pos = world
.transforms .transforms
.with(character_entity, |t| t.position) .with(bubble_entity, |t| t.position)
.unwrap_or(Vec3::ZERO); .unwrap_or(Vec3::ZERO);
let player_entity = world
.player_tags
.all()
.into_iter()
.next()
.expect("no player entity");
let player_pos = world
.transforms
.with(player_entity, |t| t.position)
.unwrap_or(Vec3::ZERO);
let velocity = player_pos - bubble_pos;
let projectile_entity = world.spawn(); let projectile_entity = world.spawn();
world.transforms.insert( world
projectile_entity, .transforms
Transform::from_position(character_pos + Vec3::new(0.0, 1.5, 0.0)), .insert(projectile_entity, Transform::from_position(bubble_pos));
);
world world
.names .names
.insert(projectile_entity, "DialogProjectile".to_string()); .insert(projectile_entity, "DialogProjectile".to_string());
@@ -207,6 +204,7 @@ fn tick_displaying_bubbles(world: &mut World, delta: f32)
bubble_entity, bubble_entity,
correct_parry, correct_parry,
parry_window_open: false, parry_window_open: false,
velocity,
}, },
); );

View File

@@ -5,6 +5,7 @@ pub mod dialog_render;
pub mod dialog_system; pub mod dialog_system;
pub mod follow; pub mod follow;
pub mod input; pub mod input;
pub mod particle;
pub mod physics_sync; pub mod physics_sync;
pub mod render; pub mod render;
pub mod rotate; 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_render::dialog_bubble_render_system;
pub use dialog_system::dialog_system; pub use dialog_system::dialog_system;
pub use input::player_input_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 physics_sync::physics_sync_system;
pub use render::render_system; pub use render::render_system;
pub use rotate::rotate_system; pub use rotate::rotate_system;

196
src/systems/particle.rs Normal file
View File

@@ -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<Particle>,
pub instances: Vec<ParticleInstanceRaw>,
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<SpawnParticleIntent> = 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
}

View File

@@ -7,6 +7,7 @@ use crate::components::dissolve::DissolveComponent;
use crate::components::follow::FollowComponent; use crate::components::follow::FollowComponent;
use crate::components::intent::{CameraTransitionIntent, FollowPlayerIntent, StopFollowingIntent}; use crate::components::intent::{CameraTransitionIntent, FollowPlayerIntent, StopFollowingIntent};
use crate::components::lights::spot::SpotlightComponent; use crate::components::lights::spot::SpotlightComponent;
use crate::components::particle::SpawnParticleIntent;
use crate::components::player_states::{ use crate::components::player_states::{
FallingState, IdleState, JumpingState, LeapingState, RollingState, WalkingState, FallingState, IdleState, JumpingState, LeapingState, RollingState, WalkingState,
}; };
@@ -18,8 +19,10 @@ use crate::components::{
}; };
use crate::debug::DebugMode; use crate::debug::DebugMode;
use crate::entity::{EntityHandle, EntityManager}; use crate::entity::{EntityHandle, EntityManager};
use crate::loaders::mesh::Mesh;
use crate::render::snow::SnowLayer; use crate::render::snow::SnowLayer;
use crate::states::state::StateMachine; use crate::states::state::StateMachine;
use crate::systems::particle::ParticleBuffers;
pub use crate::utility::transform::Transform; pub use crate::utility::transform::Transform;
@@ -111,6 +114,8 @@ pub struct World
pub bubble_tags: Storage<()>, pub bubble_tags: Storage<()>,
pub projectile_tags: Storage<()>, pub projectile_tags: Storage<()>,
pub dialog_outcomes: Vec<DialogOutcomeEvent>, pub dialog_outcomes: Vec<DialogOutcomeEvent>,
pub spawn_particle_intents: Vec<SpawnParticleIntent>,
pub particle_buffers: Option<ParticleBuffers>,
// --- intents (one-frame, consumed after processing) --- // --- intents (one-frame, consumed after processing) ---
pub follow_player_intents: Storage<FollowPlayerIntent>, pub follow_player_intents: Storage<FollowPlayerIntent>,
@@ -121,6 +126,8 @@ pub struct World
pub snow_layer: Option<SnowLayer>, pub snow_layer: Option<SnowLayer>,
pub debug_mode: DebugMode, pub debug_mode: DebugMode,
pub was_dialog_active: bool, pub was_dialog_active: bool,
pub gizmo_mesh: Option<Mesh>,
pub gizmo_instance_buffer: Option<wgpu::Buffer>,
} }
impl World impl World
@@ -160,12 +167,16 @@ impl World
bubble_tags: Storage::new(), bubble_tags: Storage::new(),
projectile_tags: Storage::new(), projectile_tags: Storage::new(),
dialog_outcomes: Vec::new(), dialog_outcomes: Vec::new(),
spawn_particle_intents: Vec::new(),
particle_buffers: None,
follow_player_intents: Storage::new(), follow_player_intents: Storage::new(),
stop_following_intents: Storage::new(), stop_following_intents: Storage::new(),
camera_transition_intents: Storage::new(), camera_transition_intents: Storage::new(),
snow_layer: None, snow_layer: None,
debug_mode: DebugMode::default(), debug_mode: DebugMode::default(),
was_dialog_active: false, was_dialog_active: false,
gizmo_mesh: None,
gizmo_instance_buffer: None,
} }
} }