diff --git a/CHANGELOG.md b/CHANGELOG.md index 1834c707..c3f46fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a new `EffectProperties` component holding the runtime values for all properties of a single `ParticleEffect` instance. This component can be added manually to the same `Entity` holding the `ParticleEffect` if you want to set initial values different from the default ones declared in the `EffectAsset`. Otherwise Hanabi will add the component automatically. - Added a new `EffectSystems::UpdatePropertiesFromAsset` set running in the `PostUpdate` schedule. During this set, Hanabi automatically updates all `EffectProperties` if the properties declared in the underlying `EffectAsset` changed. +- Added `OrientModifier::rotation`, an optional expression which allows rotating the particle within its oriented plane. The actual meaning depends on the `OrientMode` used. (#258) +- Added 4 new scalar float attributes `F32_0` to `F32_3`, which have no specified meaning but instead can be used to store any per-particle value. ### Changed - Properties of an effect have been moved from `CompiledParticleEffect` to a new `EffectProperties` component. This splits the semantic of the `CompiledParticleEffect`, which is purely an internal optimization, from the list of properties stored in `EffectProperties`, which is commonly accessed by the user to assign new values to properties. - Thanks to the split of properties into `EffectProperties`, change detection now works on properties, and uploads to GPU will only occur when change detection triggered on the component. Previously properties were re-uploaded each frame to the GPU even if unchanged. - Effect properties are now reflected (via the new `EffectProperties` component). +- `Attribute::ALL` is now private; use `Attribute::all()` instead. ## [0.8.0] 2023-11-08 diff --git a/examples/billboard.rs b/examples/billboard.rs index f41788e7..84d98fc2 100644 --- a/examples/billboard.rs +++ b/examples/billboard.rs @@ -99,11 +99,21 @@ fn setup( speed: (writer.lit(0.5) + writer.lit(0.2) * writer.rand(ScalarType::Float)).expr(), }; + // Use the F32_0 attribute as a per-particle rotation value, initialized on + // spawn and constant after. The rotation angle is in radians, here randomly + // selected in [0:2*PI]. + let init_rotation = (writer.rand(ScalarType::Float) * writer.lit(std::f32::consts::TAU)).expr(); + let init_rotation = SetAttributeModifier::new(Attribute::F32_0, init_rotation); + // Bounce the alpha cutoff value between 0 and 1, to show its effect on the // alpha masking let alpha_cutoff = ((writer.time() * writer.lit(2.)).sin() * writer.lit(0.5) + writer.lit(0.5)).expr(); + // The rotation of the OrientModifier is read from the F32_0 attribute (our + // per-particle rotation) + let rotation = writer.attr(Attribute::F32_0).expr(); + let effect = effects.add( EffectAsset::new(32768, Spawner::rate(64.0.into()), writer.finish()) .with_name("billboard") @@ -112,12 +122,14 @@ fn setup( .init(init_vel) .init(init_age) .init(init_lifetime) + .init(init_rotation) .render(ParticleTextureModifier { texture: texture_handle, sample_mapping: ImageSampleMapping::ModulateOpacityFromR, }) .render(OrientModifier { - mode: OrientMode::ParallelCameraDepthPlane, + mode: OrientMode::FaceCameraPosition, + rotation: Some(rotation), }) .render(ColorOverLifetimeModifier { gradient }) .render(SizeOverLifetimeModifier { diff --git a/examples/expr.rs b/examples/expr.rs index 74e5f5b8..b93dbce1 100644 --- a/examples/expr.rs +++ b/examples/expr.rs @@ -119,9 +119,7 @@ fn setup(mut commands: Commands, mut effects: ResMut>) { gradient: size_gradient, screen_space_size: false, }) - .render(OrientModifier { - mode: OrientMode::AlongVelocity, - }), + .render(OrientModifier::new(OrientMode::AlongVelocity)), ); commands.spawn(( diff --git a/examples/init.rs b/examples/init.rs index 1036463a..e9cd4aa9 100644 --- a/examples/init.rs +++ b/examples/init.rs @@ -72,9 +72,7 @@ where .with_name(name) .with_simulation_space(SimulationSpace::Local) .init(init) - .render(OrientModifier { - mode: OrientMode::FaceCameraPosition, - }) + .render(OrientModifier::new(OrientMode::FaceCameraPosition)) .render(SetColorModifier { color: COLOR.into(), }) diff --git a/examples/multicam.rs b/examples/multicam.rs index c7376c2c..86f0c6fe 100644 --- a/examples/multicam.rs +++ b/examples/multicam.rs @@ -94,9 +94,7 @@ fn make_effect(color: Color) -> EffectAsset { gradient: size_gradient.clone(), screen_space_size: false, }) - .render(OrientModifier { - mode: OrientMode::FaceCameraPosition, - }) + .render(OrientModifier::new(OrientMode::FaceCameraPosition)) } fn setup( diff --git a/examples/portal.rs b/examples/portal.rs index fd48ebaa..f0cb84b8 100644 --- a/examples/portal.rs +++ b/examples/portal.rs @@ -114,9 +114,7 @@ fn setup(mut commands: Commands, mut effects: ResMut>) { gradient: size_gradient1, screen_space_size: false, }) - .render(OrientModifier { - mode: OrientMode::AlongVelocity, - }), + .render(OrientModifier::new(OrientMode::AlongVelocity)), ); commands.spawn(( diff --git a/src/asset.rs b/src/asset.rs index bf90be50..ca9a41e5 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -592,15 +592,9 @@ mod tests { .render(ParticleTextureModifier::default()) .render(ColorOverLifetimeModifier::default()) .render(SizeOverLifetimeModifier::default()) - .render(OrientModifier { - mode: OrientMode::ParallelCameraDepthPlane, - }) - .render(OrientModifier { - mode: OrientMode::FaceCameraPosition, - }) - .render(OrientModifier { - mode: OrientMode::AlongVelocity, - }); + .render(OrientModifier::new(OrientMode::ParallelCameraDepthPlane)) + .render(OrientModifier::new(OrientMode::FaceCameraPosition)) + .render(OrientModifier::new(OrientMode::AlongVelocity)); assert_eq!(effect.capacity, 4096); @@ -643,18 +637,12 @@ mod tests { ParticleTextureModifier::default().apply_render(&mut module, &mut render_context); ColorOverLifetimeModifier::default().apply_render(&mut module, &mut render_context); SizeOverLifetimeModifier::default().apply_render(&mut module, &mut render_context); - OrientModifier { - mode: OrientMode::ParallelCameraDepthPlane, - } - .apply_render(&mut module, &mut render_context); - OrientModifier { - mode: OrientMode::FaceCameraPosition, - } - .apply_render(&mut module, &mut render_context); - OrientModifier { - mode: OrientMode::AlongVelocity, - } - .apply_render(&mut module, &mut render_context); + OrientModifier::new(OrientMode::ParallelCameraDepthPlane) + .apply_render(&mut module, &mut render_context); + OrientModifier::new(OrientMode::FaceCameraPosition) + .apply_render(&mut module, &mut render_context); + OrientModifier::new(OrientMode::AlongVelocity) + .apply_render(&mut module, &mut render_context); // assert_eq!(effect.render_layout, render_layout); } diff --git a/src/attributes.rs b/src/attributes.rs index d50bbc31..52e568b3 100644 --- a/src/attributes.rs +++ b/src/attributes.rs @@ -424,6 +424,26 @@ impl AttributeInner { Value::Scalar(ScalarValue::Int(0)), ); + pub const F32_0: &'static AttributeInner = &AttributeInner::new( + Cow::Borrowed("f32_0"), + Value::Scalar(ScalarValue::Float(0.)), + ); + + pub const F32_1: &'static AttributeInner = &AttributeInner::new( + Cow::Borrowed("f32_1"), + Value::Scalar(ScalarValue::Float(0.)), + ); + + pub const F32_2: &'static AttributeInner = &AttributeInner::new( + Cow::Borrowed("f32_2"), + Value::Scalar(ScalarValue::Float(0.)), + ); + + pub const F32_3: &'static AttributeInner = &AttributeInner::new( + Cow::Borrowed("f32_3"), + Value::Scalar(ScalarValue::Float(0.)), + ); + #[inline] pub(crate) const fn new(name: Cow<'static, str>, default_value: Value) -> Self { Self { @@ -438,8 +458,8 @@ impl AttributeInner { /// Effects are composed of many simulated particles. Each particle is in turn /// composed of a set of attributes, which are used to simulate and render it. /// Common attributes include the particle's position, its age, or its color. -/// See [`Attribute::ALL`] for a list of supported attributes. Custom attributes -/// are not supported. +/// See [`Attribute::all()`] for a list of supported attributes. Custom +/// attributes are not supported. /// /// Attributes are indirectly added to an effect by adding [modifiers] requiring /// them. Each modifier documents its required attributes. You can force a @@ -852,8 +872,68 @@ impl Attribute { /// [`FlipbookModifier`]: crate::modifier::output::FlipbookModifier pub const SPRITE_INDEX: Attribute = Attribute(AttributeInner::SPRITE_INDEX); + /// A generic scalar float attribute. + /// + /// This attribute can be used for anything. It has no specific meaning. You + /// can store whatever per-particle value you want in it (for example, at + /// spawn time) and read it back later. + /// + /// # Name + /// + /// `f32_0` + /// + /// # Type + /// + /// [`ScalarType::Float`] + pub const F32_0: Attribute = Attribute(AttributeInner::F32_0); + + /// A generic scalar float attribute. + /// + /// This attribute can be used for anything. It has no specific meaning. You + /// can store whatever per-particle value you want in it (for example, at + /// spawn time) and read it back later. + /// + /// # Name + /// + /// `f32_1` + /// + /// # Type + /// + /// [`ScalarType::Float`] + pub const F32_1: Attribute = Attribute(AttributeInner::F32_1); + + /// A generic scalar float attribute. + /// + /// This attribute can be used for anything. It has no specific meaning. You + /// can store whatever per-particle value you want in it (for example, at + /// spawn time) and read it back later. + /// + /// # Name + /// + /// `f32_2` + /// + /// # Type + /// + /// [`ScalarType::Float`] + pub const F32_2: Attribute = Attribute(AttributeInner::F32_2); + + /// A generic scalar float attribute. + /// + /// This attribute can be used for anything. It has no specific meaning. You + /// can store whatever per-particle value you want in it (for example, at + /// spawn time) and read it back later. + /// + /// # Name + /// + /// `f32_3` + /// + /// # Type + /// + /// [`ScalarType::Float`] + pub const F32_3: Attribute = Attribute(AttributeInner::F32_3); + /// Collection of all the existing particle attributes. - pub const ALL: [Attribute; 13] = [ + const ALL: [Attribute; 17] = [ Attribute::POSITION, Attribute::VELOCITY, Attribute::AGE, @@ -867,11 +947,15 @@ impl Attribute { Attribute::AXIS_Y, Attribute::AXIS_Z, Attribute::SPRITE_INDEX, + Attribute::F32_0, + Attribute::F32_1, + Attribute::F32_2, + Attribute::F32_3, ]; /// Retrieve an attribute by its name. /// - /// See [`Attribute::ALL`] for the list of attributes, and the + /// See [`Attribute::all()`] for the list of attributes, and the /// [`Attribute::name()`] method of each attribute for their name. /// /// # Example @@ -888,6 +972,20 @@ impl Attribute { .copied() } + /// Get the list of all existing attributes. + /// + /// # Example + /// + /// ``` + /// # use bevy_hanabi::*; + /// for attr in Attribute::all() { + /// println!("{}", attr.name()); + /// } + /// ``` + pub fn all() -> &'static [Attribute] { + &Self::ALL + } + /// The attribute's name. /// /// The name of an attribute is unique, and corresponds to the name of the @@ -1419,8 +1517,8 @@ mod tests { #[test] fn attr_from_name() { - for attr in Attribute::ALL { - assert_eq!(Attribute::from_name(attr.name()), Some(attr)); + for attr in Attribute::all() { + assert_eq!(Attribute::from_name(attr.name()), Some(*attr)); } } diff --git a/src/lib.rs b/src/lib.rs index 16eed86b..1acd84cf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1973,8 +1973,10 @@ else { return c1; } // Import bevy_render::view for the render shader { - // It's reasonably hard to retrieve the source code for view.wgsl in bevy_render. We use a few tricks to get a Shader - // that we can then convert into a composable module (which is how imports work in Bevy itself). + // It's reasonably hard to retrieve the source code for view.wgsl in + // bevy_render. We use a few tricks to get a Shader that we can + // then convert into a composable module (which is how imports work in Bevy + // itself). let mut dummy_app = App::new(); dummy_app.init_resource::>(); dummy_app.add_plugins(bevy::render::view::ViewPlugin); diff --git a/src/modifier/mod.rs b/src/modifier/mod.rs index 888fc834..794b0961 100644 --- a/src/modifier/mod.rs +++ b/src/modifier/mod.rs @@ -1028,15 +1028,9 @@ fn main() {{ &ParticleTextureModifier::default(), &ColorOverLifetimeModifier::default(), &SizeOverLifetimeModifier::default(), - &OrientModifier { - mode: OrientMode::ParallelCameraDepthPlane, - }, - &OrientModifier { - mode: OrientMode::FaceCameraPosition, - }, - &OrientModifier { - mode: OrientMode::AlongVelocity, - }, + &OrientModifier::new(OrientMode::ParallelCameraDepthPlane), + &OrientModifier::new(OrientMode::FaceCameraPosition), + &OrientModifier::new(OrientMode::AlongVelocity), ]; for &modifier in modifiers.iter() { let mut module = Module::default(); diff --git a/src/modifier/output.rs b/src/modifier/output.rs index cd844a0e..1da038dc 100644 --- a/src/modifier/output.rs +++ b/src/modifier/output.rs @@ -5,8 +5,8 @@ use serde::{Deserialize, Serialize}; use std::hash::Hash; use crate::{ - impl_mod_render, Attribute, BoxedModifier, CpuValue, Gradient, Modifier, ModifierContext, - Module, RenderContext, RenderModifier, ShaderCode, ToWgslString, + impl_mod_render, Attribute, BoxedModifier, CpuValue, EvalContext, ExprHandle, Gradient, + Modifier, ModifierContext, Module, RenderContext, RenderModifier, ShaderCode, ToWgslString, }; /// Mapping of the sample read from a texture image to the base particle color. @@ -160,7 +160,7 @@ impl RenderModifier for ColorOverLifetimeModifier { /// This modifier does not require any specific particle attribute. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Reflect, Serialize, Deserialize)] pub struct SetSizeModifier { - /// The particle color. + /// The 2D particle (quad) size. pub size: CpuValue, /// Is the particle size in screen-space logical pixel? If `true`, the size /// is in screen-space logical pixels, and not affected by the camera @@ -232,9 +232,11 @@ pub enum OrientMode { /// Orient a particle such that its local XY plane is parallel to the /// camera's near and far planes (depth planes). /// - /// The local X axis is (1,0,0) in camera space, and the local Y axis is - /// (0,1,0). The local Z axis is (0,0,1), perpendicular to the camera depth - /// planes, pointing toward the camera. + /// By default the local X axis is (1,0,0) in camera space, and the local Y + /// axis is (0,1,0). The local Z axis is (0,0,1), perpendicular to the + /// camera depth planes, pointing toward the camera. If an + /// [`OrientModifier::rotation`] is provided, it defines a rotation in the + /// local X-Y plane, relative to that default. /// /// This mode is a bit cheaper to calculate than [`FaceCameraPosition`], and /// should be preferred to it unless the particle absolutely needs to have @@ -250,7 +252,8 @@ pub enum OrientMode { /// /// The local Z axis of the particle points directly at the camera position. /// The X and Y axes form an orthonormal frame with it, where Y is roughly - /// upward. + /// upward. If an [`OrientModifier::rotation`] is provided, it defines a + /// rotation in the local X-Y plane, relative to that default. /// /// This mode is a bit more costly to calculate than /// [`ParallelCameraDepthPlane`], and should be used only when the @@ -268,12 +271,16 @@ pub enum OrientMode { /// particles (quads) to roughly face the camera position (as long as /// velocity is not perpendicular to the camera depth plane), while having /// their X axis always pointing alongside the velocity. + /// + /// With this mode, any provided [`OrientModifier::rotation`] is ignored. AlongVelocity, } /// Orients the particle's local frame. /// -/// The orientation is calculated during the rendering of each particle. +/// The orientation is calculated during the rendering of each particle. An +/// additional in-plane rotation can be optionally specified; its meaning +/// depends on the [`OrientMode`] in use. /// /// # Attributes /// @@ -291,6 +298,28 @@ pub enum OrientMode { pub struct OrientModifier { /// Orientation mode for the particles. pub mode: OrientMode, + /// Optional in-plane rotation expression, as a single `f32` angle in + /// radians. + /// + /// The actual meaning depends on [`OrientMode`], and the rotation may be + /// ignored for some mode(s). + pub rotation: Option, +} + +impl OrientModifier { + /// Create a new instance of this modifier with the given orient mode. + pub fn new(mode: OrientMode) -> Self { + Self { + mode, + ..Default::default() + } + } + + /// Set the rotation expression for the particles. + pub fn with_rotation(mut self, rotation: ExprHandle) -> Self { + self.rotation = Some(rotation); + self + } } #[typetag::serde] @@ -325,20 +354,51 @@ impl Modifier for OrientModifier { #[typetag::serde] impl RenderModifier for OrientModifier { - fn apply_render(&self, _module: &mut Module, context: &mut RenderContext) { + fn apply_render(&self, module: &mut Module, context: &mut RenderContext) { match self.mode { OrientMode::ParallelCameraDepthPlane => { - context.vertex_code += r#"let cam_rot = get_camera_rotation_effect_space(); + if let Some(rotation) = self.rotation { + let rotation = context.eval(module, rotation).unwrap(); + context.vertex_code += &format!( + r#"let cam_rot = get_camera_rotation_effect_space(); +let particle_rot_in_cam_space = {}; +let particle_rot_in_cam_space_cos = cos(particle_rot_in_cam_space); +let particle_rot_in_cam_space_sin = sin(particle_rot_in_cam_space); +axis_x = cam_rot[0].xyz * particle_rot_in_cam_space_cos + cam_rot[1].xyz * particle_rot_in_cam_space_sin; +axis_y = cam_rot[0].xyz * particle_rot_in_cam_space_sin - cam_rot[1].xyz * particle_rot_in_cam_space_cos; +axis_z = cam_rot[2].xyz; +"#, + rotation + ); + } else { + context.vertex_code += r#"let cam_rot = get_camera_rotation_effect_space(); axis_x = cam_rot[0].xyz; axis_y = cam_rot[1].xyz; axis_z = cam_rot[2].xyz; "#; + } } OrientMode::FaceCameraPosition => { - context.vertex_code += r#"axis_z = normalize(get_camera_position_effect_space() - position); + if let Some(rotation) = self.rotation { + let rotation = context.eval(module, rotation).unwrap(); + context.vertex_code += &format!( + r#"axis_z = normalize(get_camera_position_effect_space() - position); +let particle_rot_in_cam_space = {}; +let particle_rot_in_cam_space_cos = cos(particle_rot_in_cam_space); +let particle_rot_in_cam_space_sin = sin(particle_rot_in_cam_space); +let axis_x0 = normalize(cross(view.view[1].xyz, axis_z)); +let axis_y0 = cross(axis_z, axis_x0); +axis_x = axis_x0 * particle_rot_in_cam_space_cos + axis_y0 * particle_rot_in_cam_space_sin; +axis_y = axis_x0 * particle_rot_in_cam_space_sin - axis_y0 * particle_rot_in_cam_space_cos; +"#, + rotation + ); + } else { + context.vertex_code += r#"axis_z = normalize(get_camera_position_effect_space() - position); axis_x = normalize(cross(view.view[1].xyz, axis_z)); axis_y = cross(axis_z, axis_x); "#; + } } OrientMode::AlongVelocity => { context.vertex_code += r#"let dir = normalize(position - get_camera_position_effect_space()); @@ -534,9 +594,73 @@ mod tests { } #[test] - fn mod_billboard() { + fn mod_set_color() { + let mut modifier = SetColorModifier::default(); + assert_eq!(modifier.context(), ModifierContext::Render); + assert!(modifier.as_render().is_some()); + assert!(modifier.as_render_mut().is_some()); + assert_eq!(modifier.boxed_clone().context(), ModifierContext::Render); + + let mut module = Module::default(); + let property_layout = PropertyLayout::default(); + let particle_layout = ParticleLayout::default(); + let mut context = RenderContext::new(&property_layout, &particle_layout); + modifier.apply_render(&mut module, &mut context); + + assert_eq!(modifier.color, CpuValue::from(Vec4::ZERO)); + assert_eq!(context.vertex_code, "color = vec4(0.,0.,0.,0.);\n"); + } + + #[test] + fn mod_set_size() { + let mut modifier = SetSizeModifier::default(); + assert_eq!(modifier.context(), ModifierContext::Render); + assert!(modifier.as_render().is_some()); + assert!(modifier.as_render_mut().is_some()); + assert_eq!(modifier.boxed_clone().context(), ModifierContext::Render); + + let mut module = Module::default(); + let property_layout = PropertyLayout::default(); + let particle_layout = ParticleLayout::default(); + let mut context = RenderContext::new(&property_layout, &particle_layout); + modifier.apply_render(&mut module, &mut context); + + assert_eq!(modifier.size, CpuValue::from(Vec2::ZERO)); + assert_eq!(context.vertex_code, "size = vec2(0.,0.);\n"); + } + + #[test] + fn mod_orient() { + let mut modifier = OrientModifier::default(); + assert_eq!(modifier.context(), ModifierContext::Render); + assert!(modifier.as_render().is_some()); + assert!(modifier.as_render_mut().is_some()); + assert_eq!(modifier.boxed_clone().context(), ModifierContext::Render); + } + + #[test] + fn mod_orient_default() { + let mut module = Module::default(); let modifier = OrientModifier::default(); + let property_layout = PropertyLayout::default(); + let particle_layout = ParticleLayout::default(); + let mut context = RenderContext::new(&property_layout, &particle_layout); + modifier.apply_render(&mut module, &mut context); + + // TODO - less weak test... + assert!(context + .vertex_code + .contains("get_camera_rotation_effect_space")); + assert!(!context + .vertex_code + .contains("cos(particle_rot_in_cam_space)")); + assert!(!context.vertex_code.contains("let axis_x0 =")); + } + + #[test] + fn mod_orient_rotation() { let mut module = Module::default(); + let modifier = OrientModifier::default().with_rotation(module.lit(1.)); let property_layout = PropertyLayout::default(); let particle_layout = ParticleLayout::default(); let mut context = RenderContext::new(&property_layout, &particle_layout); @@ -546,5 +670,29 @@ mod tests { assert!(context .vertex_code .contains("get_camera_rotation_effect_space")); + assert!(context + .vertex_code + .contains("cos(particle_rot_in_cam_space)")); + assert!(!context.vertex_code.contains("let axis_x0 =")); + } + + #[test] + fn mod_orient_rotation_face_camera() { + let mut module = Module::default(); + let modifier = + OrientModifier::new(OrientMode::FaceCameraPosition).with_rotation(module.lit(1.)); + let property_layout = PropertyLayout::default(); + let particle_layout = ParticleLayout::default(); + let mut context = RenderContext::new(&property_layout, &particle_layout); + modifier.apply_render(&mut module, &mut context); + + // TODO - less weak test... + assert!(context + .vertex_code + .contains("get_camera_position_effect_space")); + assert!(context + .vertex_code + .contains("cos(particle_rot_in_cam_space)")); + assert!(context.vertex_code.contains("let axis_x0 =")); } }