Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ preprocessor = { path = "node-graph/preprocessor" }
math-parser = { path = "libraries/math-parser" }
graphene-application-io = { path = "node-graph/libraries/application-io" }
graphene-resource = { path = "node-graph/libraries/resources" }
graphene-animation = { path = "node-graph/libraries/animation" }
core-types = { path = "node-graph/libraries/core-types" }
no-std-types = { path = "node-graph/libraries/no-std-types" }
raster-types = { path = "node-graph/libraries/raster-types" }
Expand Down
1 change: 1 addition & 0 deletions node-graph/graph-craft/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ core-types = { workspace = true, features = ["serde"] }
brush-nodes = { workspace = true, features = ["serde"] }
graphene-core = { workspace = true, features = ["serde"] }
graphene-application-io = { workspace = true, features = ["serde"] }
graphene-animation = { workspace = true, features = ["serde"] }
rendering = { workspace = true, features = ["serde"] }
raster-nodes = { workspace = true, features = ["serde"] }
vector-nodes = { workspace = true, features = ["serde"] }
Expand Down
2 changes: 2 additions & 0 deletions node-graph/graph-craft/src/document/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use core_types::{CacheHash, Color, ContextFeatures, MemoHash, Node, Type, TypeDe
use dyn_any::DynAny;
pub use dyn_any::StaticType;
pub use glam::{DAffine2, DVec2, IVec2, UVec2};
use graphene_animation::AnimationCurve;
use graphene_application_io::resource::ResourceHash;
use graphene_application_io::resource::ResourceId;
use graphic_types::raster_types::{CPU, Image, Raster};
Expand Down Expand Up @@ -540,6 +541,7 @@ tagged_value! {
VectorModification(Box<VectorModification>),
ImageData(Image<Color>),
Resource(ResourceId),
AnimationCurve(AnimationCurve),
// Legacy
#[serde(alias = "OptionalDAffine2")]
LegacyOptionalDAffine2(Option<DAffine2>),
Expand Down
1 change: 1 addition & 0 deletions node-graph/interpreted-executor/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ wasm = ["graphene-std/wasm"]
graphene-std = { workspace = true }
graph-craft = { workspace = true }
graphene-core = { workspace = true }
graphene-animation = { workspace = true }
wgpu-executor = { workspace = true }
core-types = { workspace = true }
dyn-any = { workspace = true }
Expand Down
2 changes: 2 additions & 0 deletions node-graph/interpreted-executor/src/node_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use graph_craft::application_io::PlatformEditorApi;
use graph_craft::application_io::resource::Resource;
use graph_craft::document::value::RenderOutput;
use graph_craft::proto::{NodeConstructor, TypeErasedBox};
use graphene_animation::AnimationCurve;
use graphene_std::animation::RealTimeMode;
use graphene_std::any::DynAnyNode;
use graphene_std::brush::Stroke;
Expand Down Expand Up @@ -371,6 +372,7 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
InterpolationDistribution,
RowsOrColumns,
Resource,
AnimationCurve,
)
};
}
Expand Down
26 changes: 26 additions & 0 deletions node-graph/libraries/animation/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[package]
name = "graphene-animation"
description = "Animation primitives for Graphene node system"
version.workspace = true
license.workspace = true
authors.workspace = true
edition.workspace = true
repository.workspace = true
readme.workspace = true
homepage.workspace = true
rust-version.workspace = true
publish.workspace = true

[dependencies]
serde = { workspace = true, optional = true }
kurbo = { workspace = true }
glam = { workspace = true }
dyn-any = { workspace = true }
graphene-hash = { workspace = true, features = ["derive"] }

[features]
default = ["serde"]
serde = ["dep:serde"]

[lints]
workspace = true
250 changes: 250 additions & 0 deletions node-graph/libraries/animation/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
//! Animation Curves

use dyn_any::DynAny;

use glam::{DVec2, FloatExt};
use graphene_hash::CacheHash;
use kurbo::{CubicBez, ParamCurve, Point};
use serde::Deserialize;

// 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<DVec2>,
pub knot: DVec2,
pub interp_behavior: InterpolationBehavior,
}
impl Keyframe {
pub fn new_linear(knot: DVec2, left_handle: Option<DVec2>) -> Self {
Self {
left_handle,
knot,
interp_behavior: InterpolationBehavior::Linear,
}
}
pub fn new_constant(knot: DVec2, left_handle: Option<DVec2>) -> Self {
Self {
left_handle,
knot,
interp_behavior: InterpolationBehavior::Constant,
}
}
pub fn new_bezier(knot: DVec2, left_handle: Option<DVec2>, 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 {
#[serde(deserialize_with = "deserialize_keyframes")]
keyframes: Vec<Keyframe>, // not public to maintain sorted order
}

impl AnimationCurve {
pub const 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;
}

let index = match self.keyframes.binary_search_by(|kf| kf.knot.x.total_cmp(&time)) {
Ok(index) => return self.keyframes[index].knot.y,
Err(0) => return self.keyframes[0].knot.y,
Err(index) if index == self.keyframes.len() => return self.keyframes[index - 1].knot.y,
Err(index) => index,
};

let segment_start = &self.keyframes[index - 1];
let segment_end = &self.keyframes[index];
let start = segment_start.knot;
let end = segment_end.knot;

match segment_start.interp_behavior {
InterpolationBehavior::Bezier { right_handle } => {
let left_handle = segment_end.left_handle.unwrap_or(end);

// Clamp the handle x-coordinates of the handles to inside the segment.
// This prevents the curve from folding over itself and having multiple values of t where x(t) == time.
let right_x = right_handle.x.clamp(start.x, end.x);
let left_x = left_handle.x.clamp(right_x, end.x);

let curve = CubicBez::new(
Point::new(start.x, start.y),
Point::new(right_x, right_handle.y),
Point::new(left_x, left_handle.y),
Point::new(end.x, end.y),
);

// Find the value of t where curve.x == time to find the value
let t = kurbo::common::solve_itp(|t| curve.eval(t).x - time, 0.0, 1.0, 1e-7, 1, 0.2, start.x - time, end.x - time);

curve.eval(t).y
}
InterpolationBehavior::Constant => start.y,
InterpolationBehavior::Linear => start.y.lerp(end.y, (time - start.x) / (end.x - start.x)),
}
}

pub fn keyframes(&self) -> &[Keyframe] {
&self.keyframes
}

/// Pushes a new keyframe, overwriting one with the same x-value.
/// Returns the index of the keyframe.
///
/// # Panics
///
/// This method panics if a keyframe with a non-finite x-coordinate is given.
pub fn insert_keyframe(&mut self, keyframe: Keyframe) -> usize {
assert!(keyframe.knot.x.is_finite(), "Keyframes must have a finite x-coordinate");

match self.keyframes.binary_search_by(|kf| kf.knot.x.partial_cmp(&keyframe.knot.x).unwrap_or(std::cmp::Ordering::Equal)) {
// Overwrite a keyframe with the same x-value
Ok(idx) => {
self.keyframes[idx] = keyframe;
idx
}
Err(idx) => {
self.keyframes.insert(idx, keyframe);
idx
}
}
}

pub fn remove_keyframe(&mut self, idx: usize) -> Option<Keyframe> {
if idx >= self.keyframes.len() {
return None;
}
Some(self.keyframes.remove(idx))
}
}

/// Deserialize a list of keyframes, ensuring they meet all evaluate preconditions
fn deserialize_keyframes<'de, D>(deserializer: D) -> Result<Vec<Keyframe>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw_keyframes = <Vec<Keyframe>>::deserialize(deserializer)?;
let mut temp_curve = AnimationCurve::new();
// use the existing logic for pushing keyframes to ensure the curve is valid
for kf in raw_keyframes {
if kf.knot.x.is_finite() {
temp_curve.insert_keyframe(kf);
}
}
Ok(temp_curve.keyframes)
}

#[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.insert_keyframe(Keyframe {
left_handle: None,
knot: DVec2::new(1.0, 10.0),
interp_behavior: InterpolationBehavior::Constant,
});
assert_eq!(single_kf.evaluate(0.0), 10.0);
assert_eq!(single_kf.evaluate(2.0), 10.0);
}

#[test]
pub fn bezier_segment() {
let mut anim_curve = AnimationCurve::new();
anim_curve.insert_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.insert_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).abs() < 0.01);
assert!((anim_curve.evaluate(0.75) - 0.896).abs() < 0.01);
}

#[test]
pub fn simple_segments() {
let mut anim_curve = AnimationCurve::new();
anim_curve.insert_keyframe(Keyframe {
left_handle: None,
knot: DVec2::new(0.0, 0.0),
interp_behavior: InterpolationBehavior::Linear,
});
anim_curve.insert_keyframe(Keyframe {
left_handle: None,
knot: DVec2::new(1.0, 1.0),
interp_behavior: InterpolationBehavior::Constant,
});
anim_curve.insert_keyframe(Keyframe {
left_handle: None,
knot: DVec2::new(2.0, 0.0),
interp_behavior: InterpolationBehavior::Constant,
});
anim_curve.insert_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() {
let mut anim_curve = AnimationCurve::new();
anim_curve.insert_keyframe(Keyframe {
left_handle: None,
knot: DVec2::new(0.0, 0.0),
interp_behavior: InterpolationBehavior::Constant,
});
anim_curve.insert_keyframe(Keyframe {
left_handle: None,
knot: DVec2::new(1.0, 5.0),
interp_behavior: InterpolationBehavior::Constant,
});
anim_curve.insert_keyframe(Keyframe {
left_handle: None,
knot: DVec2::new(2.0, -3.0),
interp_behavior: InterpolationBehavior::Constant,
});

assert_eq!(anim_curve.evaluate(-1.0), 0.0);
assert_eq!(anim_curve.evaluate(0.0), 0.0);
assert_eq!(anim_curve.evaluate(0.5), 0.0);
assert_eq!(anim_curve.evaluate(1.0), 5.0);
assert_eq!(anim_curve.evaluate(2.0), -3.0);
}
}
1 change: 1 addition & 0 deletions node-graph/nodes/gcore/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ graphene-hash = { workspace = true }
raster-types = { workspace = true }
graphic-types = { workspace = true }
node-macro = { workspace = true }
graphene-animation = { workspace = true }

# Workspace dependencies
dyn-any = { workspace = true }
Expand Down
Loading
Loading