diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs index bba723ae2fb..df108d9c502 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs @@ -683,7 +683,7 @@ impl NodeNetworkInterface { let layer_output = NodeInput::node(layer.to_node(), 0); match post_node_input { - NodeInput::Value { .. } | NodeInput::Scope(_) | NodeInput::Inline(_) | NodeInput::Reflection(_) => { + NodeInput::Value { .. } | NodeInput::Timeline { .. } | NodeInput::Scope(_) | NodeInput::Inline(_) | NodeInput::Reflection(_) => { // First child in the stack: wire layer output to the post_node input self.set_input_for_import(&post_node, layer_output, network_path); } @@ -855,7 +855,7 @@ impl NodeNetworkInterface { if !inserting_into_stack { match post_node_input { // Create a new stack - NodeInput::Value { .. } | NodeInput::Scope(_) | NodeInput::Inline(_) | NodeInput::Reflection(_) => { + NodeInput::Value { .. } | NodeInput::Timeline { .. } | NodeInput::Scope(_) | NodeInput::Inline(_) | NodeInput::Reflection(_) => { self.create_wire(&OutputConnector::primary_output(layer.to_node()), &post_node, network_path); let final_layer_position = after_move_post_layer_position + IVec2::new(-LAYER_INDENT_OFFSET, STACK_VERTICAL_GAP); @@ -881,7 +881,7 @@ impl NodeNetworkInterface { } else { match post_node_input { // Move to the bottom of the stack - NodeInput::Value { .. } | NodeInput::Scope(_) | NodeInput::Inline(_) | NodeInput::Reflection(_) => { + NodeInput::Value { .. } | NodeInput::Timeline { .. } | NodeInput::Scope(_) | NodeInput::Inline(_) | NodeInput::Reflection(_) => { let offset = after_move_post_layer_position - previous_layer_position + IVec2::new(0, STACK_VERTICAL_GAP + height_above_layer); self.shift_absolute_node_position(&layer.to_node(), offset, network_path); self.create_wire(&OutputConnector::primary_output(layer.to_node()), &post_node, network_path); diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/resolved_types.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/resolved_types.rs index e9fb310e9a6..160301d70b6 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/resolved_types.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/resolved_types.rs @@ -163,6 +163,7 @@ impl NodeNetworkInterface { } NodeInput::Value { tagged_value, .. } => TypeSource::TaggedValue(tagged_value.ty()), + NodeInput::Timeline { .. } => TypeSource::TaggedValue(concrete!(f64)), NodeInput::Import { import_index, .. } => { // Get the input type of the encapsulating node input let Some((encapsulating_node, encapsulating_path)) = network_path.split_last() else { diff --git a/node-graph/graph-craft/src/document.rs b/node-graph/graph-craft/src/document.rs index 8e237af3416..778ffd82538 100644 --- a/node-graph/graph-craft/src/document.rs +++ b/node-graph/graph-craft/src/document.rs @@ -210,6 +210,11 @@ pub enum NodeInput { tagged_value: MemoHash, exposed: bool, }, + /// A reference to an [`AnimationCurve`](core_types::animation::AnimationCurve) on the timeline. + /// Gets converted into an AnimationCurve node during graph compilation. + Timeline { + curve_id: u64, + }, // TODO: Remove import_type and get type from parent node input /// Input that is provided by the import from the parent network to this document node network. @@ -286,6 +291,7 @@ impl NodeInput { match self { NodeInput::Node { .. } => true, NodeInput::Value { exposed, .. } => *exposed, + NodeInput::Timeline { .. } => false, NodeInput::Import { .. } => true, NodeInput::Inline(_) => false, NodeInput::Scope(_) => false, @@ -297,6 +303,7 @@ impl NodeInput { match self { NodeInput::Node { .. } => unreachable!("ty() called on NodeInput::Node"), NodeInput::Value { tagged_value, .. } => tagged_value.ty(), + NodeInput::Timeline { .. } => concrete!(f64), // Stored import types are normalized to their structural form once at document migration NodeInput::Import { import_type, .. } => import_type.clone(), NodeInput::Inline(_) => panic!("ty() called on NodeInput::Inline"), @@ -944,6 +951,14 @@ impl NodeNetwork { return; }; + Self::replace_timeline_inputs_with_nodes( + &mut inner_network.exports, + &mut inner_network.nodes, + node.original_location.path.as_ref().unwrap_or(&vec![]), + gen_id, + map_ids, + id, + ); // Replace value and reflection imports with value nodes, added inside nested network Self::replace_value_inputs_with_nodes( &mut inner_network.exports, @@ -994,6 +1009,7 @@ impl NodeNetwork { *import_index = parent_input_index; } NodeInput::Value { .. } => unreachable!("Value inputs should have been replaced with value nodes"), + NodeInput::Timeline { .. } => unreachable!("Value inputs should have been replaced with animation curve nodes"), NodeInput::Inline(_) => (), NodeInput::Scope(_) => unreachable!("Scope inputs should have been resolved by resolve_scope_inputs_recursive before flattening"), NodeInput::Reflection(_) => unreachable!("Reflection inputs should have been replaced with value nodes"), @@ -1032,6 +1048,44 @@ impl NodeNetwork { } } + fn replace_timeline_inputs_with_nodes( + inputs: &mut [NodeInput], + collection: &mut FxHashMap, + path: &[NodeId], + gen_id: impl Fn() -> NodeId + Copy, + map_ids: impl Fn(NodeId, NodeId) -> NodeId + Copy, + id: NodeId, + ) { + for input in inputs { + let NodeInput::Timeline { curve_id } = *input else { continue }; + + let curve_node_id = gen_id(); + let merged_node_id = map_ids(id, curve_node_id); + let mut original_location = OriginalLocation { + path: Some(path.to_vec()), + dependants: vec![vec![id]], + ..Default::default() + }; + if let Some(path) = &mut original_location.path { + path.push(curve_node_id); + } + + collection.insert( + merged_node_id, + DocumentNode { + inputs: vec![NodeInput::value(TaggedValue::U64(curve_id), false)], + implementation: DocumentNodeImplementation::ProtoNode(graphene_core::animation::animation_curve::IDENTIFIER), + original_location, + ..Default::default() + }, + ); + *input = NodeInput::Node { + node_id: merged_node_id, + output_index: 0, + }; + } + } + #[inline(never)] fn replace_value_inputs_with_nodes( inputs: &mut [NodeInput], diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index f7db9480732..8303585d422 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -6,6 +6,7 @@ use brush_nodes::{BrushCache, Stroke}; use core_types::color::SRGBA8; use core_types::list::{Item, List, NodeIdPath}; use core_types::transfer_curve::TransferCurve; +use core_types::animation::AnimationCurve; use core_types::transform::Footprint; use core_types::{CacheHash, Color, ContextFeatures, MemoHash, Node, Type, TypeDescriptor}; use dyn_any::DynAny; @@ -544,6 +545,7 @@ tagged_value! { LegacyOptionalDAffine2(Option), #[serde(alias = "FillGradient")] LegacyGradient(graphic_types::migrations::legacy::LegacyGradient), + AnimationCurve(AnimationCurve), // ========== // ENUM TYPES // ========== diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index c639fe2a7d8..d0e714bcc4d 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -1,3 +1,4 @@ +use core_types::animation::AnimationCurve; use dyn_any::StaticType; use glam::{DAffine2, DVec2}; use graph_craft::application_io::PlatformEditorApi; @@ -91,6 +92,9 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Item, Context => Item]), async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Item, Context => Item]), async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => ListDyn, Context => Item]), + async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => AnimationCurve, Context => graphene_std::ContextFeatures]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => AnimationCurve]), + #[cfg(target_family = "wasm")] async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Item, Context => Item]), async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Item<&PlatformEditorApi>, Context => Item]), @@ -145,6 +149,8 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<&PlatformEditorApi>]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => AnimationCurve]), + #[cfg(feature = "gpu")] async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List>]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), diff --git a/node-graph/libraries/core-types/src/animation.rs b/node-graph/libraries/core-types/src/animation.rs new file mode 100644 index 00000000000..7ed28f36c90 --- /dev/null +++ b/node-graph/libraries/core-types/src/animation.rs @@ -0,0 +1,202 @@ +//! Animation Curve implementation based off of Blender's fcurves. +//! + +use dyn_any::DynAny; + +use glam::DVec2; +use graphene_hash::CacheHash; +use kurbo::{CubicBez, ParamCurve, Point}; + +// Every keyframe defines a left handle point for any bezier easings to the left, +// and info defining the behavior to the right hand side of the keyframe +#[derive(Debug, Clone, Copy, PartialEq, CacheHash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Keyframe { + /// If None, defaults to knot in the case of a bezier keyframe to the left. + pub left_handle: Option, + pub knot: DVec2, + pub interp_behavior: InterpolationBehavior, +} +impl Keyframe { + pub fn new_linear(knot: DVec2, left_handle: Option) -> Self { + Self { + left_handle, + knot, + interp_behavior: InterpolationBehavior::Linear, + } + } + pub fn new_constant(knot: DVec2, left_handle: Option) -> Self { + Self { + left_handle, + knot, + interp_behavior: InterpolationBehavior::Constant, + } + } + pub fn new_bezier(knot: DVec2, left_handle: Option, right_handle: DVec2) -> Self { + Self { + left_handle, + knot, + interp_behavior: InterpolationBehavior::Bezier { right_handle }, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, CacheHash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum InterpolationBehavior { + Bezier { right_handle: DVec2 }, + Constant, + Linear, +} + +#[derive(Default, Debug, Clone, PartialEq, DynAny, CacheHash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct AnimationCurve { + keyframes: Vec, // not public to maintain sorted order +} + +impl AnimationCurve { + pub fn new() -> Self { + Self { keyframes: Vec::new() } + } + + pub fn evaluate(&self, time: f64) -> f64 { + if self.keyframes.is_empty() || !time.is_finite() { + return 0.0; + } + + // keyframes should (hopefully) have finite, real coordinates + let index = self.keyframes.binary_search_by(|kf| kf.knot.x.partial_cmp(&time).unwrap_or(std::cmp::Ordering::Equal)); + + // We are on a keyframe, use its knot + if let Ok(idx) = index { + return self.keyframes[idx].knot.y; + } + + let index = index.unwrap_err(); + + if index == 0 { + return 0.0; + } else if index == self.keyframes.len() { + // unwrap is safe because of the non-empty guard at the top + return self.keyframes.last().unwrap().knot.y; + } + + let segment_start = &self.keyframes[index - 1]; + let segment_end = &self.keyframes[index]; + + match segment_start.interp_behavior { + InterpolationBehavior::Bezier { right_handle } => { + let to_point = |vec: DVec2| Point::new(vec.x, vec.y); + + let curve = CubicBez::new( + to_point(segment_start.knot), + to_point(right_handle), + segment_end.left_handle.map(|end| to_point(end)).unwrap_or_else(|| to_point(segment_end.knot)), + to_point(segment_end.knot), + ); + + // Find the value of t where curve.x == time to find the value + //TODO: find proper values for epsilon and k1. The docs suggest 0.2 for k1 but epsilon should be tested with several values + let t = kurbo::common::solve_itp(|t| curve.eval(t).x - time, 0.0, 1.0, 0.00001, 1, 0.2, segment_start.knot.x - time, segment_end.knot.x - time); + + curve.eval(t).y + } + InterpolationBehavior::Constant => segment_start.knot.y, + InterpolationBehavior::Linear => { + let start = segment_start.knot.y; + let end = segment_end.knot.y; + let i = (time - segment_start.knot.x) / (segment_end.knot.x - segment_start.knot.x); + + start + (end - start) * i + } + } + } + + pub fn keyframes(&self) -> &[Keyframe] { + &self.keyframes + } + + pub fn push_keyframe(&mut self, keyframe: Keyframe) { + self.keyframes.push(keyframe); + self.keyframes.sort_by(|lhs, rhs| lhs.knot.x.partial_cmp(&rhs.knot.x).unwrap_or(std::cmp::Ordering::Equal)); + } + pub fn remove_keyframe(&mut self, idx: usize) -> Option { + if idx >= self.keyframes.len() { + return None; + } + Some(self.keyframes.remove(idx)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + pub fn out_of_bounds() { + let empty_curve = AnimationCurve::new(); + assert_eq!(empty_curve.evaluate(10.0), 0.0); + + let mut single_kf = AnimationCurve::new(); + single_kf.push_keyframe(Keyframe { + left_handle: None, + knot: DVec2::new(1.0, 10.0), + interp_behavior: InterpolationBehavior::Constant, + }); + assert_eq!(single_kf.evaluate(0.0), 0.0); + assert_eq!(single_kf.evaluate(2.0), 10.0); + } + + #[test] + pub fn bezier_segment() { + let mut anim_curve = AnimationCurve::new(); + anim_curve.push_keyframe(Keyframe { + left_handle: None, + knot: DVec2::new(0.0, 0.0), + interp_behavior: InterpolationBehavior::Bezier { right_handle: DVec2::new(0.5, 0.0) }, + }); + anim_curve.push_keyframe(Keyframe { + left_handle: Some(DVec2::new(0.5, 1.0)), + knot: DVec2::new(1.0, 1.0), + interp_behavior: InterpolationBehavior::Constant, + }); + + assert_eq!(anim_curve.evaluate(0.5), 0.5); + assert!(anim_curve.evaluate(0.25) - 0.104 < 0.01); + assert!(anim_curve.evaluate(0.75) - 0.896 < 0.01); + } + + #[test] + pub fn simple_segments() { + let mut anim_curve = AnimationCurve::new(); + anim_curve.push_keyframe(Keyframe { + left_handle: None, + knot: DVec2::new(0.0, 0.0), + interp_behavior: InterpolationBehavior::Linear, + }); + anim_curve.push_keyframe(Keyframe { + left_handle: None, + knot: DVec2::new(1.0, 1.0), + interp_behavior: InterpolationBehavior::Constant, + }); + anim_curve.push_keyframe(Keyframe { + left_handle: None, + knot: DVec2::new(2.0, 0.0), + interp_behavior: InterpolationBehavior::Constant, + }); + anim_curve.push_keyframe(Keyframe { + left_handle: None, + knot: DVec2::new(3.0, 1.0), + interp_behavior: InterpolationBehavior::Constant, + }); + + assert_eq!(anim_curve.evaluate(0.5), 0.5); + assert_eq!(anim_curve.evaluate(0.25), 0.25); + assert_eq!(anim_curve.evaluate(0.75), 0.75); + + assert_eq!(anim_curve.evaluate(2.5), 0.0); + } + + #[test] + pub fn constant_segment() {} +} diff --git a/node-graph/libraries/core-types/src/lib.rs b/node-graph/libraries/core-types/src/lib.rs index 979ec960471..4271ad9353b 100644 --- a/node-graph/libraries/core-types/src/lib.rs +++ b/node-graph/libraries/core-types/src/lib.rs @@ -1,5 +1,6 @@ extern crate log; +pub mod animation; pub mod bounds; pub mod consts; pub mod context; diff --git a/node-graph/nodes/gcore/src/animation.rs b/node-graph/nodes/gcore/src/animation.rs index 561c7d04355..e94d46d753c 100644 --- a/node-graph/nodes/gcore/src/animation.rs +++ b/node-graph/nodes/gcore/src/animation.rs @@ -1,4 +1,6 @@ use core_types::list::{Item, List}; +use core_types::animation::{AnimationCurve, Keyframe}; + use core_types::transform::Footprint; use core_types::{CacheHash, CloneVarArgs, Color, Context, Ctx, ExtractAll, ExtractAnimationTime, ExtractPointerPosition, ExtractRealTime, OwnedContextImpl}; use glam::{DAffine2, DVec2}; @@ -27,6 +29,18 @@ pub enum AnimationTimeMode { FrameNumber, } +/// Evaluate the value of an animation curve with the given time +#[node_macro::node(category("Animation"))] +fn animation_curve(ctx: impl Ctx + ExtractAnimationTime, curve_id: u64) -> f64 { + let time = ctx.try_animation_time().unwrap_or_default(); + + let mut curve = AnimationCurve::new(); + curve.push_keyframe(Keyframe::new_linear(DVec2::new(0.0, 0.0), None)); + curve.push_keyframe(Keyframe::new_constant(DVec2::new(1.0, 1.0), None)); + + curve.evaluate(time) +} + /// Produces a chosen representation of the current real time and date (in UTC) based on the system clock. #[node_macro::node(category("Animation"))] fn real_time( diff --git a/node-graph/nodes/gcore/src/context_modification.rs b/node-graph/nodes/gcore/src/context_modification.rs index 024473fafbf..be37556b377 100644 --- a/node-graph/nodes/gcore/src/context_modification.rs +++ b/node-graph/nodes/gcore/src/context_modification.rs @@ -1,4 +1,5 @@ use core::f64; +use core_types::animation::AnimationCurve; use core_types::context::{CloneVarArgs, Context, ContextFeatures, Ctx, ExtractAll}; use core_types::list::{AttributeValueDyn, Item, List, ListDyn, NodeIdPath}; use core_types::transform::Footprint; @@ -44,6 +45,7 @@ async fn context_modification( Context -> List, Context -> List, Context -> ListDyn, + Context -> AnimationCurve, )] value: impl Node, Output = T>, /// The parts of the context to keep when evaluating the input value. All other parts are nullified.