Skip to content

Commit ecd1d65

Browse files
committed
Add basic support for particle trails.
This commit implements simple fixed-length particle trails in Hanabi. They're stored in a ring buffer with a fixed capacity separate from the main particle buffer. Currently, for simplicity, trail particles are rendered as exact duplicates of the head particles. Nothing in this patch prevents this from being expanded further to support custom rendering for trail particles, including ribbons and trail-index-dependent rendering, in the future. The only reason why this wasn't implemented is to keep the size of this patch manageable, as it's quite large as it is. The size of the trail buffer is known as the `trail_capacity` and doesn't change over the lifetime of the effect. The length of each particle trail is known as the `trail_length` and can be altered at runtime. The interval at which new trail particles spawn is known as the `trail_period` and can likewise change at runtime. There are three primary reasons why particle trails are stored in a separate buffer from the head particles: 1. It's common to want a separate rendering for trail particles and head particles (e.g. the head particle may want to be some sort of particle with a short ribbon behind it), and so we need to separate the two so that they can be rendered in separate drawcalls. 2. Having a separate buffer allows us to skip the update phase for particle trails, enhancing performance. 3. Since trail particles are strictly LIFO, we can use a ring buffer instead of a freelist, which both saves memory (as no freelist needs to be maintained) and enhances performance (as an entire chunk of particles can be freed at once instead of having to do so one by one). The core of the implementation is the `render::effect_cache::TrailChunks` buffer. The long documentation comment attached to that structure explains the setup of the ring buffer and has a diagram. In summary, two parallel ring buffers are maintained on CPU and GPU. The GPU ring buffer has `trail_capacity` entries and stores the trail particles themselves, while the CPU one has `trail_length` entries and stores pointers to indices defining the boundaries of the chunks. A new example, `worms`, has been added in order to demonstrate simple use of trails. This example can be updated over time as new trail features are added.
1 parent 5bf400f commit ecd1d65

14 files changed

+1528
-488
lines changed

Cargo.toml

+4
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,10 @@ required-features = [ "bevy/bevy_winit", "bevy/bevy_pbr", "bevy/png", "3d" ]
144144
name = "2d"
145145
required-features = [ "bevy/bevy_winit", "bevy/bevy_sprite", "2d" ]
146146

147+
[[example]]
148+
name = "worms"
149+
required-features = [ "bevy/bevy_winit", "bevy/bevy_pbr", "3d" ]
150+
147151
[workspace]
148152
resolver = "2"
149153
members = ["."]

assets/circle.png

897 Bytes
Loading

examples/worms.rs

+156
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
//! Worms
2+
//!
3+
//! Demonstrates simple use of particle trails.
4+
5+
use std::f32::consts::{FRAC_PI_2, PI};
6+
7+
use bevy::{
8+
core_pipeline::{bloom::BloomSettings, tonemapping::Tonemapping},
9+
log::LogPlugin,
10+
math::{vec3, vec4},
11+
prelude::*,
12+
};
13+
#[cfg(feature = "examples_world_inspector")]
14+
use bevy_inspector_egui::quick::WorldInspectorPlugin;
15+
16+
use bevy_hanabi::prelude::*;
17+
18+
fn main() {
19+
let mut app = App::default();
20+
app.add_plugins(
21+
DefaultPlugins
22+
.set(LogPlugin {
23+
level: bevy::log::Level::WARN,
24+
filter: "bevy_hanabi=warn,worms=trace".to_string(),
25+
update_subscriber: None,
26+
})
27+
.set(WindowPlugin {
28+
primary_window: Some(Window {
29+
title: "🎆 Hanabi — worms".to_string(),
30+
..default()
31+
}),
32+
..default()
33+
}),
34+
)
35+
.add_systems(Update, bevy::window::close_on_esc)
36+
.add_plugins(HanabiPlugin);
37+
38+
#[cfg(feature = "examples_world_inspector")]
39+
app.add_plugins(WorldInspectorPlugin::default());
40+
41+
app.add_systems(Startup, setup).run();
42+
}
43+
44+
fn setup(
45+
mut commands: Commands,
46+
asset_server: ResMut<AssetServer>,
47+
mut effects: ResMut<Assets<EffectAsset>>,
48+
) {
49+
commands.spawn((
50+
Camera3dBundle {
51+
transform: Transform::from_translation(Vec3::new(0., 0., 25.)),
52+
camera: Camera {
53+
hdr: true,
54+
clear_color: Color::BLACK.into(),
55+
..default()
56+
},
57+
tonemapping: Tonemapping::None,
58+
..default()
59+
},
60+
BloomSettings::default(),
61+
));
62+
63+
let circle: Handle<Image> = asset_server.load("circle.png");
64+
65+
let writer = ExprWriter::new();
66+
67+
// Init modifiers
68+
69+
// Spawn the particles within a reasonably large box.
70+
let set_initial_position_modifier = SetAttributeModifier::new(
71+
Attribute::POSITION,
72+
((writer.rand(ValueType::Vector(VectorType::VEC3F)) + writer.lit(vec3(-0.5, -0.5, 0.0)))
73+
* writer.lit(vec3(16.0, 16.0, 0.0)))
74+
.expr(),
75+
);
76+
77+
// Randomize the initial angle of the particle, storing it in the `F32_0`
78+
// scratch attribute.`
79+
let set_initial_angle_modifier = SetAttributeModifier::new(
80+
Attribute::F32_0,
81+
writer.lit(0.0).uniform(writer.lit(PI * 2.0)).expr(),
82+
);
83+
84+
// Give each particle a random opaque color.
85+
let set_color_modifier = SetAttributeModifier::new(
86+
Attribute::COLOR,
87+
(writer.rand(ValueType::Vector(VectorType::VEC4F)) * writer.lit(vec4(1.0, 1.0, 1.0, 0.0))
88+
+ writer.lit(Vec4::W))
89+
.pack4x8unorm()
90+
.expr(),
91+
);
92+
93+
// Give the particles a long lifetime.
94+
let set_lifetime_modifier =
95+
SetAttributeModifier::new(Attribute::LIFETIME, writer.lit(10.0).expr());
96+
97+
// Update modifiers
98+
99+
// Make the particle wiggle, following a sine wave.
100+
let set_velocity_modifier = SetAttributeModifier::new(
101+
Attribute::VELOCITY,
102+
WriterExpr::sin(
103+
writer.lit(vec3(1.0, 1.0, 0.0))
104+
* (writer.attr(Attribute::F32_0)
105+
+ (writer.time() * writer.lit(5.0)).sin() * writer.lit(1.0))
106+
+ writer.lit(vec3(0.0, FRAC_PI_2, 0.0)),
107+
)
108+
.mul(writer.lit(5.0))
109+
.expr(),
110+
);
111+
112+
// Render modifiers
113+
114+
// Set the particle size.
115+
let set_size_modifier = SetSizeModifier {
116+
size: Vec2::splat(0.4).into(),
117+
};
118+
119+
// Make each particle round.
120+
let particle_texture_modifier = ParticleTextureModifier {
121+
texture: circle,
122+
sample_mapping: ImageSampleMapping::Modulate,
123+
};
124+
125+
let module = writer.finish();
126+
127+
// Allocate room for 32,768 trail particles. Give each particle a 5-particle
128+
// trail, and spawn a new trail particle every ⅛ of a second.
129+
let effect = effects.add(
130+
EffectAsset::with_trails(
131+
32768,
132+
32768,
133+
Spawner::rate(4.0.into())
134+
.with_trail_length(5)
135+
.with_trail_period(0.125.into()),
136+
module,
137+
)
138+
.with_name("worms")
139+
.init(set_initial_position_modifier)
140+
.init(set_initial_angle_modifier)
141+
.init(set_lifetime_modifier)
142+
.init(set_color_modifier)
143+
.update(set_velocity_modifier)
144+
.render(set_size_modifier)
145+
.render(particle_texture_modifier),
146+
);
147+
148+
commands.spawn((
149+
Name::new("worms"),
150+
ParticleEffectBundle {
151+
effect: ParticleEffect::new(effect),
152+
transform: Transform::IDENTITY,
153+
..default()
154+
},
155+
));
156+
}

src/asset.rs

+36-1
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,11 @@ pub struct EffectAsset {
186186
/// should keep this quantity as close as possible to the maximum number of
187187
/// particles they expect to render.
188188
capacity: u32,
189+
/// Maximum number of concurrent trail particles.
190+
///
191+
/// The same caveats as [`capacity`] apply. This value can't be changed
192+
/// after the effect is created.
193+
trail_capacity: u32,
189194
/// Spawner.
190195
pub spawner: Spawner,
191196
/// For 2D rendering, the Z coordinate used as the sort key.
@@ -250,6 +255,9 @@ impl EffectAsset {
250255
/// which should be passed to this method. If expressions are not used, just
251256
/// pass an empty module [`Module::default()`].
252257
///
258+
/// This function doesn't allocate space for any trails. If you need
259+
/// particle trails, use [`with_trails`] instead.
260+
///
253261
/// # Examples
254262
///
255263
/// Create a new effect asset without any modifier. This effect doesn't
@@ -290,12 +298,30 @@ impl EffectAsset {
290298
}
291299
}
292300

301+
/// As [`new`], but reserves space for trails.
302+
///
303+
/// Use this method when you want to enable particle trails.
304+
pub fn with_trails(
305+
capacity: u32,
306+
trail_capacity: u32,
307+
spawner: Spawner,
308+
module: Module,
309+
) -> Self {
310+
Self {
311+
capacity,
312+
trail_capacity,
313+
spawner,
314+
module,
315+
..default()
316+
}
317+
}
318+
293319
/// Get the capacity of the effect, in number of particles.
294320
///
295321
/// This represents the number of particles stored in GPU memory at all
296322
/// time, even if unused, so you should try to minimize this value. However,
297323
/// the [`Spawner`] cannot emit more particles than this capacity. Whatever
298-
/// the spanwer settings, if the number of particles reaches the capacity,
324+
/// the spawner settings, if the number of particles reaches the capacity,
299325
/// no new particle can be emitted. Setting an appropriate capacity for an
300326
/// effect is therefore a compromise between more particles available for
301327
/// visuals and more GPU memory usage.
@@ -310,6 +336,15 @@ impl EffectAsset {
310336
self.capacity
311337
}
312338

339+
/// Get the trail capacity of the effect, in number of trail particles.
340+
///
341+
/// The same caveats as [`capacity`] apply here: the GPU always allocates
342+
/// space for this many trail particles, regardless of the number actually
343+
/// used.
344+
pub fn trail_capacity(&self) -> u32 {
345+
self.trail_capacity
346+
}
347+
313348
/// Get the expression module storing all expressions in use by modifiers of
314349
/// this effect.
315350
pub fn module(&self) -> &Module {

src/gradient.rs

+1-4
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,7 @@ use bevy::{
44
utils::FloatOrd,
55
};
66
use serde::{Deserialize, Serialize};
7-
use std::{
8-
hash::{Hash, Hasher},
9-
vec::Vec,
10-
};
7+
use std::hash::{Hash, Hasher};
118

129
/// Describes a type that can be linearly interpolated between two keys.
1310
///

src/lib.rs

+23-3
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,6 @@ mod spawn;
196196
#[cfg(test)]
197197
mod test_utils;
198198

199-
use properties::PropertyInstance;
200-
201199
pub use asset::{AlphaMode, EffectAsset, MotionIntegration, SimulationCondition};
202200
pub use attributes::*;
203201
pub use bundle::ParticleEffectBundle;
@@ -840,6 +838,19 @@ impl EffectShaderSource {
840838
"@group(1) @binding(2) var<storage, read> properties : Properties;".to_string()
841839
};
842840

841+
let (trail_binding_code, trail_render_indirect_binding_code) = if asset.trail_capacity()
842+
== 0
843+
{
844+
("// (no trails)".to_string(), "// (no trails)".to_string())
845+
} else {
846+
(
847+
"@group(1) @binding(3) var<storage, read_write> trail_buffer : ParticleBuffer;"
848+
.to_string(),
849+
"@group(3) @binding(1) var<storage, read_write> trail_render_indirect : TrailRenderIndirect;"
850+
.to_string(),
851+
)
852+
};
853+
843854
// Start from the base module containing the expressions actually serialized in
844855
// the asset. We will add the ones created on-the-fly by applying the
845856
// modifiers to the contexts.
@@ -968,6 +979,10 @@ impl EffectShaderSource {
968979
(String::new(), String::new())
969980
};
970981

982+
if asset.trail_capacity() > 0 {
983+
layout_flags |= LayoutFlags::TRAILS_BUFFER_PRESENT;
984+
}
985+
971986
(
972987
render_context.vertex_code,
973988
render_context.fragment_code,
@@ -1040,7 +1055,12 @@ impl EffectShaderSource {
10401055
.replace("{{UPDATE_CODE}}", &update_code)
10411056
.replace("{{UPDATE_EXTRA}}", &update_extra)
10421057
.replace("{{PROPERTIES}}", &properties_code)
1043-
.replace("{{PROPERTIES_BINDING}}", &properties_binding_code);
1058+
.replace("{{PROPERTIES_BINDING}}", &properties_binding_code)
1059+
.replace("{{TRAIL_BINDING}}", &trail_binding_code)
1060+
.replace(
1061+
"{{TRAIL_RENDER_INDIRECT_BINDING}}",
1062+
&trail_render_indirect_binding_code,
1063+
);
10441064
trace!("Configured update shader:\n{}", update_shader_source);
10451065

10461066
// Configure the render shader template, and make sure a corresponding shader

src/render/batch.rs

+14-2
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ pub(crate) struct EffectBatch {
4949
///
5050
/// [`ParticleEffect`]: crate::ParticleEffect
5151
pub entities: Vec<u32>,
52+
/// Whether trails are active for this effect (present with a nonzero
53+
/// length).
54+
pub trails_active: bool,
5255
}
5356

5457
impl EffectBatch {
@@ -74,6 +77,7 @@ impl EffectBatch {
7477
#[cfg(feature = "2d")]
7578
z_sort_key_2d: input.z_sort_key_2d,
7679
entities: vec![input.entity_index],
80+
trails_active: input.trail_capacity > 0 && input.trail_length > 0,
7781
}
7882
}
7983
}
@@ -98,6 +102,11 @@ pub(crate) struct BatchInput {
98102
pub image_handle: Handle<Image>,
99103
/// Number of particles to spawn for this effect.
100104
pub spawn_count: u32,
105+
pub spawn_trail_particle: bool,
106+
pub trail_length: u32,
107+
pub trail_capacity: u32,
108+
pub trail_head_chunk: u32,
109+
pub trail_tail_chunk: u32,
101110
/// Emitter transform.
102111
pub transform: GpuCompressedTransform,
103112
/// Emitter inverse transform.
@@ -295,8 +304,6 @@ impl<'a, S, B, I: Batchable<S, B>> Batcher<'a, S, B, I> {
295304

296305
#[cfg(test)]
297306
mod tests {
298-
use crate::EffectShader;
299-
300307
use super::*;
301308

302309
// Test item to batch
@@ -550,6 +557,11 @@ mod tests {
550557
layout_flags: LayoutFlags::NONE,
551558
image_handle,
552559
spawn_count: 32,
560+
spawn_trail_particle: false,
561+
trail_length: 0,
562+
trail_capacity: 0,
563+
trail_head_chunk: 0,
564+
trail_tail_chunk: 0,
553565
transform: GpuCompressedTransform::default(),
554566
inverse_transform: GpuCompressedTransform::default(),
555567
property_buffer: None,

0 commit comments

Comments
 (0)