Skip to content

Commit

Permalink
Add basic support for particle trails.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
pcwalton committed Feb 27, 2024
1 parent 5bf400f commit ba58b9b
Show file tree
Hide file tree
Showing 14 changed files with 1,528 additions and 488 deletions.
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ required-features = [ "bevy/bevy_winit", "bevy/bevy_pbr", "bevy/png", "3d" ]
name = "2d"
required-features = [ "bevy/bevy_winit", "bevy/bevy_sprite", "2d" ]

[[example]]
name = "worms"
required-features = [ "bevy/bevy_winit", "bevy/bevy_pbr", "3d" ]

[workspace]
resolver = "2"
members = ["."]
Binary file added assets/circle.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
156 changes: 156 additions & 0 deletions examples/worms.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
//! Worms
//!
//! Demonstrates simple use of particle trails.
use std::f32::consts::{FRAC_PI_2, PI};

use bevy::{
core_pipeline::{bloom::BloomSettings, tonemapping::Tonemapping},
log::LogPlugin,
math::{vec3, vec4},
prelude::*,
};
#[cfg(feature = "examples_world_inspector")]
use bevy_inspector_egui::quick::WorldInspectorPlugin;

use bevy_hanabi::prelude::*;

fn main() {
let mut app = App::default();
app.add_plugins(
DefaultPlugins
.set(LogPlugin {
level: bevy::log::Level::WARN,
filter: "bevy_hanabi=warn,worms=trace".to_string(),
update_subscriber: None,
})
.set(WindowPlugin {
primary_window: Some(Window {
title: "🎆 Hanabi — worms".to_string(),
..default()
}),
..default()
}),
)
.add_systems(Update, bevy::window::close_on_esc)
.add_plugins(HanabiPlugin);

#[cfg(feature = "examples_world_inspector")]
app.add_plugins(WorldInspectorPlugin::default());

app.add_systems(Startup, setup).run();
}

fn setup(
mut commands: Commands,
asset_server: ResMut<AssetServer>,
mut effects: ResMut<Assets<EffectAsset>>,
) {
commands.spawn((
Camera3dBundle {
transform: Transform::from_translation(Vec3::new(0., 0., 25.)),
camera: Camera {
hdr: true,
clear_color: Color::BLACK.into(),
..default()
},
tonemapping: Tonemapping::None,
..default()
},
BloomSettings::default(),
));

let circle: Handle<Image> = asset_server.load("circle.png");

let writer = ExprWriter::new();

// Init modifiers

// Spawn the particles within a reasonably large box.
let set_initial_position_modifier = SetAttributeModifier::new(
Attribute::POSITION,
((writer.rand(ValueType::Vector(VectorType::VEC3F)) + writer.lit(vec3(-0.5, -0.5, 0.0)))
* writer.lit(vec3(16.0, 16.0, 0.0)))
.expr(),
);

// Randomize the initial angle of the particle, storing it in the `F32_0`
// scratch attribute.`
let set_initial_angle_modifier = SetAttributeModifier::new(
Attribute::F32_0,
writer.lit(0.0).uniform(writer.lit(PI * 2.0)).expr(),
);

// Give each particle a random opaque color.
let set_color_modifier = SetAttributeModifier::new(
Attribute::COLOR,
(writer.rand(ValueType::Vector(VectorType::VEC4F)) * writer.lit(vec4(1.0, 1.0, 1.0, 0.0))
+ writer.lit(Vec4::W))
.pack4x8unorm()
.expr(),
);

// Give the particles a long lifetime.
let set_lifetime_modifier =
SetAttributeModifier::new(Attribute::LIFETIME, writer.lit(10.0).expr());

// Update modifiers

// Make the particle wiggle, following a sine wave.
let set_velocity_modifier = SetAttributeModifier::new(
Attribute::VELOCITY,
WriterExpr::sin(
writer.lit(vec3(1.0, 1.0, 0.0))
* (writer.attr(Attribute::F32_0)
+ (writer.time() * writer.lit(5.0)).sin() * writer.lit(1.0))
+ writer.lit(vec3(0.0, FRAC_PI_2, 0.0)),
)
.mul(writer.lit(5.0))
.expr(),
);

// Render modifiers

// Set the particle size.
let set_size_modifier = SetSizeModifier {
size: Vec2::splat(0.4).into(),
};

// Make each particle round.
let particle_texture_modifier = ParticleTextureModifier {
texture: circle,
sample_mapping: ImageSampleMapping::Modulate,
};

let module = writer.finish();

// Allocate room for 32,768 trail particles. Give each particle a 5-particle
// trail, and spawn a new trail particle every ⅛ of a second.
let effect = effects.add(
EffectAsset::with_trails(
32768,
32768,
Spawner::rate(4.0.into())
.with_trail_length(5)
.with_trail_period(0.125.into()),
module,
)
.with_name("worms")
.init(set_initial_position_modifier)
.init(set_initial_angle_modifier)
.init(set_lifetime_modifier)
.init(set_color_modifier)
.update(set_velocity_modifier)
.render(set_size_modifier)
.render(particle_texture_modifier),
);

commands.spawn((
Name::new("worms"),
ParticleEffectBundle {
effect: ParticleEffect::new(effect),
transform: Transform::IDENTITY,
..default()
},
));
}
37 changes: 36 additions & 1 deletion src/asset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,11 @@ pub struct EffectAsset {
/// should keep this quantity as close as possible to the maximum number of
/// particles they expect to render.
capacity: u32,
/// Maximum number of concurrent trail particles.
///
/// The same caveats as [`capacity`] apply. This value can't be changed
/// after the effect is created.
trail_capacity: u32,
/// Spawner.
pub spawner: Spawner,
/// For 2D rendering, the Z coordinate used as the sort key.
Expand Down Expand Up @@ -250,6 +255,9 @@ impl EffectAsset {
/// which should be passed to this method. If expressions are not used, just
/// pass an empty module [`Module::default()`].
///
/// This function doesn't allocate space for any trails. If you need
/// particle trails, use [`with_trails`] instead.
///
/// # Examples
///
/// Create a new effect asset without any modifier. This effect doesn't
Expand Down Expand Up @@ -290,12 +298,30 @@ impl EffectAsset {
}
}

/// As [`new`], but reserves space for trails.
///
/// Use this method when you want to enable particle trails.
pub fn with_trails(
capacity: u32,
trail_capacity: u32,
spawner: Spawner,
module: Module,
) -> Self {
Self {
capacity,
trail_capacity,
spawner,
module,
..default()
}
}

/// Get the capacity of the effect, in number of particles.
///
/// This represents the number of particles stored in GPU memory at all
/// time, even if unused, so you should try to minimize this value. However,
/// the [`Spawner`] cannot emit more particles than this capacity. Whatever
/// the spanwer settings, if the number of particles reaches the capacity,
/// the spawner settings, if the number of particles reaches the capacity,
/// no new particle can be emitted. Setting an appropriate capacity for an
/// effect is therefore a compromise between more particles available for
/// visuals and more GPU memory usage.
Expand All @@ -310,6 +336,15 @@ impl EffectAsset {
self.capacity
}

/// Get the trail capacity of the effect, in number of trail particles.
///
/// The same caveats as [`capacity`] apply here: the GPU always allocates
/// space for this many trail particles, regardless of the number actually
/// used.
pub fn trail_capacity(&self) -> u32 {
self.trail_capacity
}

/// Get the expression module storing all expressions in use by modifiers of
/// this effect.
pub fn module(&self) -> &Module {
Expand Down
5 changes: 1 addition & 4 deletions src/gradient.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ use bevy::{
utils::FloatOrd,
};
use serde::{Deserialize, Serialize};
use std::{
hash::{Hash, Hasher},
vec::Vec,
};
use std::hash::{Hash, Hasher};

/// Describes a type that can be linearly interpolated between two keys.
///
Expand Down
26 changes: 23 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,6 @@ mod spawn;
#[cfg(test)]
mod test_utils;

use properties::PropertyInstance;

pub use asset::{AlphaMode, EffectAsset, MotionIntegration, SimulationCondition};
pub use attributes::*;
pub use bundle::ParticleEffectBundle;
Expand Down Expand Up @@ -840,6 +838,19 @@ impl EffectShaderSource {
"@group(1) @binding(2) var<storage, read> properties : Properties;".to_string()
};

let (trail_binding_code, trail_render_indirect_binding_code) = if asset.trail_capacity()
== 0
{
("// (no trails)".to_string(), "// (no trails)".to_string())
} else {
(
"@group(1) @binding(3) var<storage, read_write> trail_buffer : ParticleBuffer;"
.to_string(),
"@group(3) @binding(1) var<storage, read_write> trail_render_indirect : TrailRenderIndirect;"
.to_string(),
)
};

// Start from the base module containing the expressions actually serialized in
// the asset. We will add the ones created on-the-fly by applying the
// modifiers to the contexts.
Expand Down Expand Up @@ -968,6 +979,10 @@ impl EffectShaderSource {
(String::new(), String::new())
};

if asset.trail_capacity() > 0 {
layout_flags |= LayoutFlags::TRAILS_BUFFER_PRESENT;
}

(
render_context.vertex_code,
render_context.fragment_code,
Expand Down Expand Up @@ -1040,7 +1055,12 @@ impl EffectShaderSource {
.replace("{{UPDATE_CODE}}", &update_code)
.replace("{{UPDATE_EXTRA}}", &update_extra)
.replace("{{PROPERTIES}}", &properties_code)
.replace("{{PROPERTIES_BINDING}}", &properties_binding_code);
.replace("{{PROPERTIES_BINDING}}", &properties_binding_code)
.replace("{{TRAIL_BINDING}}", &trail_binding_code)
.replace(
"{{TRAIL_RENDER_INDIRECT_BINDING}}",
&trail_render_indirect_binding_code,
);
trace!("Configured update shader:\n{}", update_shader_source);

// Configure the render shader template, and make sure a corresponding shader
Expand Down
16 changes: 14 additions & 2 deletions src/render/batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ pub(crate) struct EffectBatch {
///
/// [`ParticleEffect`]: crate::ParticleEffect
pub entities: Vec<u32>,
/// Whether trails are active for this effect (present with a nonzero
/// length).
pub trails_active: bool,
}

impl EffectBatch {
Expand All @@ -74,6 +77,7 @@ impl EffectBatch {
#[cfg(feature = "2d")]
z_sort_key_2d: input.z_sort_key_2d,
entities: vec![input.entity_index],
trails_active: input.trail_capacity > 0 && input.trail_length > 0,
}
}
}
Expand All @@ -98,6 +102,11 @@ pub(crate) struct BatchInput {
pub image_handle: Handle<Image>,
/// Number of particles to spawn for this effect.
pub spawn_count: u32,
pub spawn_trail_particle: bool,
pub trail_length: u32,
pub trail_capacity: u32,
pub trail_head_chunk: u32,
pub trail_tail_chunk: u32,
/// Emitter transform.
pub transform: GpuCompressedTransform,
/// Emitter inverse transform.
Expand Down Expand Up @@ -295,8 +304,6 @@ impl<'a, S, B, I: Batchable<S, B>> Batcher<'a, S, B, I> {

#[cfg(test)]
mod tests {
use crate::EffectShader;

use super::*;

// Test item to batch
Expand Down Expand Up @@ -550,6 +557,11 @@ mod tests {
layout_flags: LayoutFlags::NONE,
image_handle,
spawn_count: 32,
spawn_trail_particle: false,
trail_length: 0,
trail_capacity: 0,
trail_head_chunk: 0,
trail_tail_chunk: 0,
transform: GpuCompressedTransform::default(),
inverse_transform: GpuCompressedTransform::default(),
property_buffer: None,
Expand Down
Loading

0 comments on commit ba58b9b

Please sign in to comment.