Compare commits
6 Commits
909ae8612a
...
87177ad97d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87177ad97d | ||
|
|
b0022ad17b | ||
|
|
d37a3c87e3 | ||
|
|
16626cc277 | ||
|
|
2846c04765 | ||
|
|
dffd731b87 |
15
.pi/skills/intent-based-architecture/SKILL.md
Normal file
15
.pi/skills/intent-based-architecture/SKILL.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -144,9 +144,9 @@
|
||||
{
|
||||
"name":"TestCharSpawn",
|
||||
"translation":[
|
||||
-381.1509704589844,
|
||||
106.53739166259766,
|
||||
107.46959686279297
|
||||
-376.7967224121094,
|
||||
115.31475830078125,
|
||||
155.05471801757812
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
18
src/components/particle.rs
Normal file
18
src/components/particle.rs
Normal 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
178
src/debug/gizmo.rs
Normal 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,
|
||||
}]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod collider_debug;
|
||||
pub mod gizmo;
|
||||
pub mod mode;
|
||||
|
||||
pub use mode::DebugMode;
|
||||
|
||||
64
src/main.rs
64
src/main.rs
@@ -22,18 +22,20 @@ use crate::bundles::terrain::{TerrainBundle, TerrainConfig};
|
||||
use crate::bundles::test_char::TestCharBundle;
|
||||
use crate::bundles::Bundle;
|
||||
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::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,
|
||||
};
|
||||
@@ -279,6 +281,15 @@ fn toggle_editor(game: &mut Game)
|
||||
.set_relative_mouse_mode(&game.window, false);
|
||||
game.editor.right_mouse_held = 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
|
||||
{
|
||||
@@ -311,7 +322,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 +361,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 +466,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>>
|
||||
|
||||
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
|
||||
@@ -460,12 +486,38 @@ fn main() -> Result<(), Box<dyn std::error::Error>>
|
||||
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.fps = 1.0 / delta;
|
||||
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 ---
|
||||
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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -28,8 +32,8 @@ use crate::paths;
|
||||
use crate::postprocess::{create_blit_pipeline, create_fullscreen_quad, LowResFramebuffer};
|
||||
use crate::texture::{DitherTextures, FlowmapTexture};
|
||||
use pipeline::{
|
||||
create_debug_lines_pipeline, create_main_pipeline, create_snow_clipmap_pipeline,
|
||||
create_wireframe_pipeline,
|
||||
create_debug_lines_pipeline, create_gizmo_lines_pipeline, create_main_pipeline,
|
||||
create_snow_clipmap_pipeline, create_wireframe_pipeline,
|
||||
};
|
||||
use std::num::NonZeroU64;
|
||||
|
||||
@@ -49,8 +53,10 @@ pub struct Renderer
|
||||
snow_clipmap_pipeline: wgpu::RenderPipeline,
|
||||
wireframe_pipeline: Option<wgpu::RenderPipeline>,
|
||||
debug_lines_pipeline: Option<wgpu::RenderPipeline>,
|
||||
gizmo_lines_pipeline: Option<wgpu::RenderPipeline>,
|
||||
debug_overlay: Option<debug_overlay::DebugOverlay>,
|
||||
billboard_pipeline: BillboardPipeline,
|
||||
particle_pipeline: ParticlePipeline,
|
||||
font_atlas: font_atlas::FontAtlas,
|
||||
text_pipeline: text_pipeline::TextPipeline,
|
||||
wireframe_supported: bool,
|
||||
@@ -526,7 +532,14 @@ impl Renderer
|
||||
&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 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);
|
||||
|
||||
@@ -558,8 +571,10 @@ impl Renderer
|
||||
snow_clipmap_pipeline,
|
||||
wireframe_pipeline,
|
||||
debug_lines_pipeline,
|
||||
gizmo_lines_pipeline,
|
||||
debug_overlay,
|
||||
billboard_pipeline,
|
||||
particle_pipeline,
|
||||
font_atlas,
|
||||
text_pipeline,
|
||||
wireframe_supported,
|
||||
@@ -607,6 +622,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,
|
||||
@@ -732,6 +748,10 @@ impl Renderer
|
||||
.debug_lines_pipeline
|
||||
.as_ref()
|
||||
.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_bind_group(0, &self.bind_group, &[offset as u32]);
|
||||
@@ -762,7 +782,7 @@ impl Renderer
|
||||
{
|
||||
if matches!(
|
||||
draw_call.pipeline,
|
||||
Pipeline::SnowClipmap | Pipeline::DebugLines
|
||||
Pipeline::SnowClipmap | Pipeline::DebugLines | Pipeline::GizmoLines
|
||||
)
|
||||
{
|
||||
continue;
|
||||
@@ -856,7 +876,7 @@ impl Renderer
|
||||
|
||||
if matches!(
|
||||
draw_call.pipeline,
|
||||
Pipeline::SnowClipmap | Pipeline::DebugLines
|
||||
Pipeline::SnowClipmap | Pipeline::DebugLines | Pipeline::GizmoLines
|
||||
)
|
||||
{
|
||||
continue;
|
||||
@@ -1068,16 +1088,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
|
||||
}
|
||||
|
||||
241
src/render/particle_pipeline.rs
Normal file
241
src/render/particle_pipeline.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
89
src/render/particle_types.rs
Normal file
89
src/render/particle_types.rs
Normal 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,92 @@
|
||||
use crate::paths;
|
||||
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(
|
||||
device: &wgpu::Device,
|
||||
bind_group_layout: &wgpu::BindGroupLayout,
|
||||
@@ -215,70 +301,7 @@ pub fn create_debug_lines_pipeline(
|
||||
bind_group_layout: &wgpu::BindGroupLayout,
|
||||
) -> 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 = 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,
|
||||
})
|
||||
create_lines_pipeline(device, format, bind_group_layout, "fs_main", "Debug Lines")
|
||||
}
|
||||
|
||||
pub fn create_snow_clipmap_pipeline(
|
||||
|
||||
@@ -143,6 +143,7 @@ pub enum Pipeline
|
||||
Standard,
|
||||
SnowClipmap,
|
||||
DebugLines,
|
||||
GizmoLines,
|
||||
}
|
||||
|
||||
pub struct DrawCall
|
||||
|
||||
@@ -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)
|
||||
var heightmap_texture: texture_2d<f32>;
|
||||
|
||||
|
||||
81
src/shaders/particle.wgsl
Normal file
81
src/shaders/particle.wgsl
Normal 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);
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,16 +172,28 @@ 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 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();
|
||||
world.transforms.insert(
|
||||
projectile_entity,
|
||||
Transform::from_position(character_pos + Vec3::new(0.0, 1.5, 0.0)),
|
||||
);
|
||||
world
|
||||
.transforms
|
||||
.insert(projectile_entity, Transform::from_position(bubble_pos));
|
||||
world
|
||||
.names
|
||||
.insert(projectile_entity, "DialogProjectile".to_string());
|
||||
@@ -207,6 +204,7 @@ fn tick_displaying_bubbles(world: &mut World, delta: f32)
|
||||
bubble_entity,
|
||||
correct_parry,
|
||||
parry_window_open: false,
|
||||
velocity,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
196
src/systems/particle.rs
Normal file
196
src/systems/particle.rs
Normal 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
|
||||
}
|
||||
11
src/world.rs
11
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,
|
||||
};
|
||||
@@ -18,8 +19,10 @@ use crate::components::{
|
||||
};
|
||||
use crate::debug::DebugMode;
|
||||
use crate::entity::{EntityHandle, EntityManager};
|
||||
use crate::loaders::mesh::Mesh;
|
||||
use crate::render::snow::SnowLayer;
|
||||
use crate::states::state::StateMachine;
|
||||
use crate::systems::particle::ParticleBuffers;
|
||||
|
||||
pub use crate::utility::transform::Transform;
|
||||
|
||||
@@ -111,6 +114,8 @@ pub struct World
|
||||
pub bubble_tags: Storage<()>,
|
||||
pub projectile_tags: Storage<()>,
|
||||
pub dialog_outcomes: Vec<DialogOutcomeEvent>,
|
||||
pub spawn_particle_intents: Vec<SpawnParticleIntent>,
|
||||
pub particle_buffers: Option<ParticleBuffers>,
|
||||
|
||||
// --- intents (one-frame, consumed after processing) ---
|
||||
pub follow_player_intents: Storage<FollowPlayerIntent>,
|
||||
@@ -121,6 +126,8 @@ pub struct World
|
||||
pub snow_layer: Option<SnowLayer>,
|
||||
pub debug_mode: DebugMode,
|
||||
pub was_dialog_active: bool,
|
||||
pub gizmo_mesh: Option<Mesh>,
|
||||
pub gizmo_instance_buffer: Option<wgpu::Buffer>,
|
||||
}
|
||||
|
||||
impl World
|
||||
@@ -160,12 +167,16 @@ 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(),
|
||||
snow_layer: None,
|
||||
debug_mode: DebugMode::default(),
|
||||
was_dialog_active: false,
|
||||
gizmo_mesh: None,
|
||||
gizmo_instance_buffer: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user