Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ members = [
"blade-graphics",
"blade-helpers",
"blade-macros",
"blade-particle",
"blade-render",
"blade-util",
"run-wasm",
Expand Down Expand Up @@ -57,6 +58,7 @@ blade-helpers = { path = "blade-helpers" }
blade-egui = { path = "blade-egui" }
blade-asset = { path = "blade-asset" }
blade-engine = { path = "blade-engine" }
blade-particle = { path = "blade-particle" }
blade-render = { path = "blade-render" }
blade-macros = { path = "blade-macros" }
bytemuck = { workspace = true }
Expand Down
1 change: 1 addition & 0 deletions blade-engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ blade-asset = { version = "0.2", path = "../blade-asset" }
blade-egui = { version = "0.7", path = "../blade-egui" }
blade-graphics = { version = "0.7", path = "../blade-graphics" }
blade-helpers = { version = "0.1", path = "../blade-helpers" }
blade-particle = { version = "0.1", path = "../blade-particle" }
blade-render = { version = "0.4", path = "../blade-render" }
base64 = { workspace = true }
choir = { workspace = true }
Expand Down
82 changes: 82 additions & 0 deletions blade-engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ use std::{ops, path::PathBuf, sync::Arc};
pub mod config;
mod trimesh;

pub use blade_particle;

const ZERO_V3: mint::Vector3<f32> = mint::Vector3 {
x: 0.0,
y: 0.0,
Expand Down Expand Up @@ -413,6 +415,9 @@ pub struct Engine {
render_objects: Vec<blade_render::Object>,
debug: blade_render::DebugConfig,
extra_debug_lines: Vec<blade_render::DebugLine>,
particle_pipeline: Option<blade_particle::ParticlePipeline>,
particle_systems: slab::Slab<blade_particle::ParticleSystem>,
surface_info: gpu::SurfaceInfo,
track_hot_reloads: bool,
workers: Vec<choir::WorkerHandle>,
choir: Arc<choir::Choir>,
Expand Down Expand Up @@ -656,6 +661,9 @@ impl Engine {
render_objects: Vec::new(),
debug: blade_render::DebugConfig::default(),
extra_debug_lines: Vec::new(),
particle_pipeline: None,
particle_systems: slab::Slab::new(),
surface_info,
track_hot_reloads: false,
workers,
choir,
Expand Down Expand Up @@ -689,6 +697,12 @@ impl Engine {
inner.destroy(&self.gpu_context);
}
}
for mut ps in self.particle_systems.drain() {
ps.destroy(&self.gpu_context);
}
if let Some(mut pp) = self.particle_pipeline.take() {
pp.destroy(&self.gpu_context);
}
self.asset_hub.destroy();
}

Expand Down Expand Up @@ -1050,6 +1064,13 @@ impl Engine {
);
}

// Update particle systems (compute passes)
if let Some(ref pipeline) = self.particle_pipeline {
for (_, system) in self.particle_systems.iter_mut() {
system.update(pipeline, command_encoder, 0.016);
}
}

command_encoder.init_texture(frame.texture());

match self.renderer {
Expand Down Expand Up @@ -1175,6 +1196,23 @@ impl Engine {
&render_camera,
&self.extra_debug_lines,
);
// Draw particle systems
if let Some(ref pipeline) = self.particle_pipeline {
let fov_x = 2.0
* ((render_camera.fov_y * 0.5).tan() * target_size.width as f32
/ target_size.height as f32)
.atan();
let particle_camera = blade_particle::CameraParams {
position: render_camera.pos.into(),
depth: render_camera.depth,
orientation: render_camera.rot.into(),
fov: [fov_x, render_camera.fov_y],
target_size: [target_size.width, target_size.height],
};
for (_, system) in self.particle_systems.iter() {
system.draw(pipeline, &mut pass, &particle_camera);
}
}
}
}
}
Expand Down Expand Up @@ -1237,6 +1275,50 @@ impl Engine {
self.extra_debug_lines.extend_from_slice(lines);
}

/// Create a particle system from an effect definition.
/// Returns a handle that can be used to trigger bursts or remove the system.
pub fn create_particle_system(
&mut self,
name: &str,
effect: &blade_particle::ParticleEffect,
) -> usize {
let pipeline = self.particle_pipeline.get_or_insert_with(|| {
blade_particle::ParticlePipeline::new(
&self.gpu_context,
blade_particle::PipelineDesc {
name: "particles",
draw_format: self.surface_info.format,
sample_count: 1,
},
)
});
let system = pipeline.create_system(&self.gpu_context, name, effect);
self.particle_systems.insert(system)
}

/// Trigger a burst of particles at a world position.
pub fn particle_burst(&mut self, handle: usize, count: u32, position: [f32; 3]) {
if let Some(system) = self.particle_systems.get_mut(handle) {
system.burst(count, position);
}
}

/// Remove a particle system and free its GPU resources.
pub fn remove_particle_system(&mut self, handle: usize) {
if self.particle_systems.contains(handle) {
let mut system = self.particle_systems.remove(handle);
system.destroy(&self.gpu_context);
}
}

/// Get a mutable reference to a particle system.
pub fn particle_system_mut(
&mut self,
handle: usize,
) -> Option<&mut blade_particle::ParticleSystem> {
self.particle_systems.get_mut(handle)
}

#[profiling::function]
pub fn populate_hud(&mut self, ui: &mut egui::Ui) {
use blade_helpers::ExposeHud as _;
Expand Down
13 changes: 13 additions & 0 deletions blade-particle/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[package]
name = "blade-particle"
version = "0.1.0"
description = "Particle system for blade"
edition = "2021"
license = "MIT"

[dependencies]
blade-graphics = { version = "0.7", path = "../blade-graphics" }
blade-macros = { version = "0.3", path = "../blade-macros" }
bytemuck = { workspace = true }
serde = { version = "1", features = ["derive"] }
ron = "0.8"
66 changes: 66 additions & 0 deletions blade-particle/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# blade-particle

GPU-driven particle system for the [blade](https://github.com/kvark/blade) engine.

## Overview

Particles are simulated on the GPU using compute shaders (reset, emit, update)
and rendered as camera-facing billboard quads. The crate is split into:

- **`ParticlePipeline`** – shared GPU pipelines, created once per surface format.
- **`ParticleSystem`** – per-instance buffers and emitter state. Many systems can
share one pipeline.

## Data format

Particle effects are defined as `ParticleEffect` structs, serializable with
[RON](https://github.com/ron-rs/ron):

```ron
(
capacity: 2000,
emitter: (
rate: 0.0,
burst_count: 0,
shape: Sphere(radius: 0.3),
),
particle: (
life: [0.3, 1.0],
speed: [3.0, 12.0],
scale: [0.03, 0.1],
color: Palette([
[255, 200, 50, 255],
[255, 120, 20, 255],
[200, 60, 10, 255],
]),
),
)
```

## Rendering backend

Particle drawing is currently integrated with the **rasterizer** backend only.
The compute simulation runs regardless of backend, but the draw call
(alpha-blended billboard quads) is issued during the rasterizer render pass.
Ray-tracing integration is not yet implemented.

## Usage

```rust
// Create pipeline (once)
let pipeline = ParticlePipeline::new(&context, PipelineDesc {
name: "particles",
draw_format: surface_format,
sample_count: 1,
});

// Create system (per effect instance)
let mut system = pipeline.create_system(&context, "explosion", &effect);

// Trigger a burst
system.burst(100, [x, y, z]);

// Each frame: simulate then draw
system.update(&pipeline, &mut encoder, dt);
system.draw(&pipeline, &mut render_pass, &camera);
```
99 changes: 99 additions & 0 deletions blade-particle/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#![allow(
// We don't use syntax sugar where it's not necessary.
clippy::match_like_matches_macro,
// Redundant matching is more explicit.
clippy::redundant_pattern_matching,
// Explicit lifetimes are often easier to reason about.
clippy::needless_lifetimes,
// No need for defaults in the internal types.
clippy::new_without_default,
// Matches are good and extendable, no need to make an exception here.
clippy::single_match,
// Push commands are more regular than macros.
clippy::vec_init_then_push,
// This is the land of unsafe.
clippy::missing_safety_doc,
)]
#![warn(
trivial_numeric_casts,
unused_extern_crates,
//TODO: re-enable. Currently doesn't like "mem::size_of" on newer Rust
//unused_qualifications,
// We don't match on a reference, unless required.
clippy::pattern_type_mismatch,
)]

mod system;

pub use system::{ParticlePipeline, ParticleSystem, PipelineDesc};

#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub enum EmitterShape {
Point,
Sphere { radius: f32 },
}

#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub struct Emitter {
/// Particles emitted per second (0 = burst-only).
pub rate: f32,
/// Number of particles per burst trigger.
pub burst_count: u32,
/// Shape from which particles originate.
pub shape: EmitterShape,
/// Half-angle of the emission cone in radians.
/// 0 = emit only along direction, PI = full sphere (default).
#[serde(default = "default_cone_angle")]
pub cone_angle: f32,
}

fn default_cone_angle() -> f32 {
std::f32::consts::PI
}

#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub enum ColorConfig {
/// Single solid color [r, g, b, a].
Solid([u8; 4]),
/// Random pick from a palette.
Palette(Vec<[u8; 4]>),
}

#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub struct ParticleConfig {
/// Lifetime range in seconds [min, max].
pub life: [f32; 2],
/// Initial speed range [min, max].
pub speed: [f32; 2],
/// Scale range [min, max].
pub scale: [f32; 2],
/// Color configuration.
pub color: ColorConfig,
}

#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub struct ParticleEffect {
/// Maximum number of live particles.
pub capacity: u32,
/// Emitter configuration.
pub emitter: Emitter,
/// Particle property ranges.
pub particle: ParticleConfig,
}

impl ParticleEffect {
pub fn load(source: &str) -> Result<Self, ron::error::SpannedError> {
ron::from_str(source)
}
}

/// Camera parameters for 3D particle projection.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Zeroable, bytemuck::Pod)]
pub struct CameraParams {
pub position: [f32; 3],
pub depth: f32,
pub orientation: [f32; 4],
pub fov: [f32; 2],
pub target_size: [u32; 2],
}
Loading
Loading