Skip to content

Commit

Permalink
Implement simple ribbon rendering. (djeedai#298)
Browse files Browse the repository at this point in the history
This adds a new render modifier, the `RibbonModifier`. When it's
present, quads are drawn between particles that are part of a trail
created with the `CloneModifier`, instead of directly at the particle
locations.

Internally, particles are threaded into a doubly linked list, with the
`PREV` and `NEXT` attributes. I chose a linked list over fixed-size
trails like the Unity VFX graph uses because (a) during ribbon
rendering, a particle only needs to know the positions of its immediate
neighbors; (b) linked lists better support variable-length trails. We
need a doubly linked list instead of a singly linked list because (a)
more sophisticated ribbon rendering will want to know about both the
next and previous particles in order to implement line joins properly;
(b) a singly linked list has difficulty dealing with dangling pointers
to dead particles.

Each ribbon quad is initially oriented toward the average normal
(`nlerp`) of the two particles that define it, modulo Gram-Schmidt
normalization. Eventually, ribbon quads should be extended to support
meshes with line joins, as well as perhaps curves, but the current
approach should do for an initial implementation.

A new example, `ribbon`, has been added.
  • Loading branch information
pcwalton authored Mar 22, 2024
1 parent d4fa724 commit be526af
Show file tree
Hide file tree
Showing 11 changed files with 308 additions and 13 deletions.
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ required-features = [ "bevy/bevy_winit", "bevy/bevy_sprite", "2d" ]
name = "worms"
required-features = [ "bevy/bevy_winit", "bevy/bevy_pbr", "bevy/png", "3d" ]

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

[workspace]
resolver = "2"
members = ["."]
145 changes: 145 additions & 0 deletions examples/ribbon.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
//! Draws a trail and connects the trails using a ribbon.
use bevy::math::vec4;
use bevy::prelude::*;
use bevy::{
core_pipeline::{bloom::BloomSettings, tonemapping::Tonemapping},
math::vec3,
};
use bevy_hanabi::prelude::*;

#[cfg(feature = "examples_world_inspector")]
use bevy_inspector_egui::quick::WorldInspectorPlugin;

// These determine the shape of the Spirograph:
// https://en.wikipedia.org/wiki/Spirograph#Mathematical_basis
const K: f32 = 0.64;
const L: f32 = 0.384;

const TIME_SCALE: f32 = 10.0;
const SHAPE_SCALE: f32 = 25.0;
const LIFETIME: f32 = 2.5;
const TRAIL_SPAWN_RATE: f32 = 256.0;

fn main() {
let mut app = App::default();
app.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "🎆 Hanabi — ribbon".to_string(),
..default()
}),
..default()
}))
.add_plugins(HanabiPlugin)
.add_systems(Update, bevy::window::close_on_esc)
.add_systems(Startup, setup)
.add_systems(Update, move_particle_effect);

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

app.run();
}

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

let writer = ExprWriter::new();

let init_position_attr = SetAttributeModifier {
attribute: Attribute::POSITION,
value: writer.lit(Vec3::ZERO).expr(),
};

let init_velocity_attr = SetAttributeModifier {
attribute: Attribute::VELOCITY,
value: writer.lit(Vec3::ZERO).expr(),
};

let init_age_attr = SetAttributeModifier {
attribute: Attribute::AGE,
value: writer.lit(0.0).expr(),
};

let init_lifetime_attr = SetAttributeModifier {
attribute: Attribute::LIFETIME,
value: writer.lit(999999.0).expr(),
};

let init_size_attr = SetAttributeModifier {
attribute: Attribute::SIZE,
value: writer.lit(0.5).expr(),
};

let clone_modifier = CloneModifier::new(1.0 / TRAIL_SPAWN_RATE, 1);

let time = writer.time().mul(writer.lit(TIME_SCALE));

let move_modifier = SetAttributeModifier {
attribute: Attribute::POSITION,
value: (WriterExpr::vec3(
writer.lit(1.0 - K).mul(time.clone().cos())
+ writer.lit(L * K) * (writer.lit((1.0 - K) / K) * time.clone()).cos(),
writer.lit(1.0 - K).mul(time.clone().sin())
- writer.lit(L * K) * (writer.lit((1.0 - K) / K) * time.clone()).sin(),
writer.lit(0.0),
) * writer.lit(SHAPE_SCALE))
.expr(),
};

let update_lifetime_attr = SetAttributeModifier {
attribute: Attribute::LIFETIME,
value: writer.lit(LIFETIME).expr(),
};

let render_color = ColorOverLifetimeModifier {
gradient: Gradient::linear(vec4(3.0, 0.0, 0.0, 1.0), vec4(3.0, 0.0, 0.0, 0.0)),
};

let effect = EffectAsset::new(
vec![256, 32768],
Spawner::once(1.0.into(), true),
writer.finish(),
)
.with_name("ribbon")
.with_simulation_space(SimulationSpace::Global)
.init(init_position_attr)
.init(init_velocity_attr)
.init(init_age_attr)
.init(init_lifetime_attr)
.init(init_size_attr)
.update_groups(move_modifier, ParticleGroupSet::single(0))
.update_groups(clone_modifier, ParticleGroupSet::single(0))
.update_groups(update_lifetime_attr, ParticleGroupSet::single(1))
.render(RibbonModifier)
.render_groups(render_color, ParticleGroupSet::single(1));

let effect = effects.add(effect);

commands
.spawn(ParticleEffectBundle {
effect: ParticleEffect::new(effect),
transform: Transform::IDENTITY,
..default()
})
.insert(Name::new("ribbon"));
}

fn move_particle_effect(mut query: Query<&mut Transform, With<ParticleEffect>>, timer: Res<Time>) {
let theta = timer.elapsed_seconds() * 1.0;
for mut transform in query.iter_mut() {
transform.translation = vec3(f32::cos(theta), f32::sin(theta), 0.0) * 5.0;
}
}
1 change: 1 addition & 0 deletions examples/worms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ fn setup(

// Update modifiers

// Clone the particle every so often. This creates the trail.
let clone_modifier = CloneModifier::new(1.0 / 8.0, 1);

// Make the particle wiggle, following a sine wave.
Expand Down
1 change: 1 addition & 0 deletions run_examples.bat
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ cargo r --example force_field --no-default-features --features="bevy/bevy_winit
cargo r --example init --no-default-features --features="bevy/bevy_winit bevy/bevy_pbr 3d examples_world_inspector"
cargo r --example lifetime --no-default-features --features="bevy/bevy_winit bevy/bevy_pbr 3d examples_world_inspector"
cargo r --example instancing --no-default-features --features="bevy/bevy_winit bevy/bevy_pbr 3d examples_world_inspector"
cargo r --example ribbon --no-default-features --features="bevy/bevy_winit bevy/bevy_pbr 3d examples_world_inspector"
REM 3D + PNG
cargo r --example gradient --no-default-features --features="bevy/bevy_winit bevy/bevy_pbr bevy/png 3d examples_world_inspector"
cargo r --example circle --no-default-features --features="bevy/bevy_winit bevy/bevy_pbr bevy/png 3d examples_world_inspector"
Expand Down
1 change: 1 addition & 0 deletions run_examples.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ cargo r --example force_field --no-default-features --features="bevy/bevy_winit
cargo r --example init --no-default-features --features="bevy/bevy_winit bevy/bevy_pbr 3d examples_world_inspector"
cargo r --example lifetime --no-default-features --features="bevy/bevy_winit bevy/bevy_pbr 3d examples_world_inspector"
cargo r --example instancing --no-default-features --features="bevy/bevy_winit bevy/bevy_pbr 3d examples_world_inspector"
cargo r --example ribbon --no-default-features --features="bevy/bevy_winit bevy/bevy_pbr 3d examples_world_inspector"
# 3D + PNG
cargo r --example gradient --no-default-features --features="bevy/bevy_winit bevy/bevy_pbr bevy/png 3d examples_world_inspector"
cargo r --example circle --no-default-features --features="bevy/bevy_winit bevy/bevy_pbr bevy/png 3d examples_world_inspector"
Expand Down
38 changes: 37 additions & 1 deletion src/attributes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,16 @@ impl AttributeInner {
Value::Vector(VectorValue::new_vec2(Vec2::ONE)),
);

pub const PREV: &'static AttributeInner = &AttributeInner::new(
Cow::Borrowed("prev"),
Value::Scalar(ScalarValue::Uint(!0u32)),
);

pub const NEXT: &'static AttributeInner = &AttributeInner::new(
Cow::Borrowed("next"),
Value::Scalar(ScalarValue::Uint(!0u32)),
);

pub const AXIS_X: &'static AttributeInner = &AttributeInner::new(
Cow::Borrowed("axis_x"),
Value::Vector(VectorValue::new_vec3(Vec3::X)),
Expand Down Expand Up @@ -949,6 +959,30 @@ impl Attribute {
/// [`VectorType::VEC2F`] representing the XY sizes of the particle.
pub const SIZE2: Attribute = Attribute(AttributeInner::SIZE2);

/// The previous particle in the ribbon chain.
///
/// # Name
///
/// `prev`
///
/// # Type
///
/// [`ScalarType::Uint`] representing the index of the previous particle in
/// the chain.
pub const PREV: Attribute = Attribute(AttributeInner::PREV);

/// The next particle in the ribbon chain.
///
/// # Name
///
/// `next`
///
/// # Type
///
/// [`ScalarType::Uint`] representing the index of the next particle in the
/// chain.
pub const NEXT: Attribute = Attribute(AttributeInner::NEXT);

/// The local X axis of the particle.
///
/// This attribute stores a per-particle X axis, which defines the
Expand Down Expand Up @@ -1096,7 +1130,7 @@ impl Attribute {
declare_custom_attr_pub!(F32X4_3, "f32x4_3", 4, VEC4F);

/// Collection of all the existing particle attributes.
const ALL: [Attribute; 29] = [
const ALL: [Attribute; 31] = [
Attribute::POSITION,
Attribute::VELOCITY,
Attribute::AGE,
Expand All @@ -1106,6 +1140,8 @@ impl Attribute {
Attribute::ALPHA,
Attribute::SIZE,
Attribute::SIZE2,
Attribute::PREV,
Attribute::NEXT,
Attribute::AXIS_X,
Attribute::AXIS_Y,
Attribute::AXIS_Z,
Expand Down
12 changes: 12 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,18 @@ impl EffectShaderSource {
return Err(ShaderGenerateError::Expr(err));
}
}

// If there are linked list attributes, clear them out.
// Not doing this is always a bug, so we might as well save the user
// some trouble.
for attribute in [Attribute::PREV, Attribute::NEXT] {
if particle_layout.contains(attribute) {
init_context
.main_code
.push_str(&format!("particle.{} = 0xffffffffu;", attribute.name()));
}
}

let sim_space_transform_code = match asset.simulation_space.eval(&init_context) {
Ok(s) => s,
Err(err) => {
Expand Down
63 changes: 54 additions & 9 deletions src/modifier/clone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,47 @@ impl Modifier for CloneModifier {

context.make_fn(
&func_name,
"particle: ptr<function, Particle>",
"particle: ptr<function, Particle>, orig_index: u32",
module,
&mut |_m: &mut Module, context: &mut dyn EvalContext| -> Result<String, ExprError> {
let age_reset_code = if context.particle_layout().contains(Attribute::AGE) {
format!("particle_buffer.particles[index].{} = 0.0;", Attribute::AGE.name())
format!("new_particle.{} = 0.0;", Attribute::AGE.name())
} else {
"".to_owned()
};

// If applicable, insert the particle into a linked list, either
// singly or doubly linked. This is typically used for ribbons.

let next_link_code = if context.particle_layout().contains(Attribute::NEXT) {
format!("new_particle.{next} = orig_index;\n", next = Attribute::NEXT.name())
} else {
"".to_owned()
};

let prev_link_code = if context.particle_layout().contains(Attribute::PREV) {
format!(
r##"
new_particle.{prev} = (*particle).{prev};
(*particle).{prev} = new_index;
"##,
prev = Attribute::PREV.name()
)
} else {
"".to_owned()
};

let double_link_code = if context.particle_layout().contains(Attribute::NEXT) &&
context.particle_layout().contains(Attribute::PREV) {
format!(
r##"
if (new_particle.{prev} < arrayLength(&particle_buffer.particles)) {{
particle_buffer.particles[new_particle.{prev}].{next} = new_index;
}}
"##,
next = Attribute::NEXT.name(),
prev = Attribute::PREV.name()
)
} else {
"".to_owned()
};
Expand All @@ -64,19 +100,28 @@ impl Modifier for CloneModifier {
// Recycle a dead particle.
let dead_index = atomicSub(&render_group_indirect[{dest}u].dead_count, 1u) - 1u;
let index = indirect_buffer.indices[3u * (base_index + dead_index) + 2u];
let new_index = indirect_buffer.indices[3u * (base_index + dead_index) + 2u];
// Copy particle in.
particle_buffer.particles[index] = *particle;
// Initialize the new particle.
var new_particle = *particle;
{age_reset_code}
// Mark as alive.
// Insert the particle between us and our current `prev`
// node, if applicable.
{next_link_code}
{prev_link_code}
{double_link_code}
// Copy the new particle into the buffer.
particle_buffer.particles[new_index] = new_particle;
// Mark it as alive.
atomicAdd(&render_group_indirect[{dest}u].alive_count, 1u);
// Add instance.
// Add an instance.
let ping = render_effect_indirect.ping;
let indirect_index = atomicAdd(&render_group_indirect[{dest}u].instance_count, 1u);
indirect_buffer.indices[3u * (base_index + indirect_index) + ping] = index;
indirect_buffer.indices[3u * (base_index + indirect_index) + ping] = new_index;
"##,
dest = self.destination_group,
))
Expand All @@ -95,7 +140,7 @@ impl Modifier for CloneModifier {
r##"
let {multiple_count} = max(0, i32(floor({b} / {m})) - i32(ceil(({b} - {delta}) / {m})) + 1);
for (var i = 0; i < {multiple_count}; i += 1) {{
{func}(&particle);
{func}(&particle, index);
}}
"##,
func = func_name,
Expand Down
2 changes: 2 additions & 0 deletions src/modifier/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ pub mod force;
pub mod kill;
pub mod output;
pub mod position;
pub mod ribbon;
pub mod velocity;

pub use accel::*;
Expand All @@ -54,6 +55,7 @@ pub use force::*;
pub use kill::*;
pub use output::*;
pub use position::*;
pub use ribbon::*;
pub use velocity::*;

use crate::{
Expand Down
Loading

0 comments on commit be526af

Please sign in to comment.