SyncTracks are now initialized from Markers, extended UnitTests and did some cleanup.

This commit is contained in:
Martin Felis 2026-01-11 21:35:51 +01:00
parent 3bb0725e3e
commit 75b4df8c65
8 changed files with 297 additions and 125 deletions

Binary file not shown.

View File

@ -2,8 +2,8 @@
[ext_resource type="PackedScene" uid="uid://d1xcqdqr1qeu6" path="res://assets/MixamoAmy.glb" id="1_0xm2m"] [ext_resource type="PackedScene" uid="uid://d1xcqdqr1qeu6" path="res://assets/MixamoAmy.glb" id="1_0xm2m"]
[ext_resource type="AnimationNodeBlendTree" uid="uid://dbkgln7hoxxc8" path="res://embedded_statemachine.tres" id="2_h2yge"] [ext_resource type="AnimationNodeBlendTree" uid="uid://dbkgln7hoxxc8" path="res://embedded_statemachine.tres" id="2_h2yge"]
[ext_resource type="AnimationLibrary" uid="uid://dwubn740aqx51" path="res://animation_library.res" id="3_h2yge"] [ext_resource type="AnimationLibrary" uid="uid://bjyfy6m2sr2kp" path="res://animation_library.tres" id="3_1bvp3"]
[ext_resource type="SyncedBlendTree" uid="uid://cjeho6848x43q" path="res://synced_blend_tree_node.tres" id="4_1bvp3"] [ext_resource type="SyncedBlendTree" uid="uid://bijslmj4wd7ap" path="res://synced_blend_tree_node_limping.tres" id="4_1bvp3"]
[node name="Node3D" type="Node3D"] [node name="Node3D" type="Node3D"]
@ -19,16 +19,15 @@ parameters/Blend2/blend_amount = 0.44
"parameters/Embedded StateMachine/conditions/is_limping" = false "parameters/Embedded StateMachine/conditions/is_limping" = false
[node name="AnimationPlayer" type="AnimationPlayer" parent="."] [node name="AnimationPlayer" type="AnimationPlayer" parent="."]
active = false
root_node = NodePath("../MixamoAmy") root_node = NodePath("../MixamoAmy")
libraries = { libraries = {
&"animation_library": ExtResource("3_h2yge") &"animation_library": ExtResource("3_1bvp3")
} }
[node name="SyncedAnimationGraph" type="SyncedAnimationGraph" parent="."] [node name="SyncedAnimationGraph" type="SyncedAnimationGraph" parent="."]
animation_player = NodePath("../AnimationPlayer") animation_player = NodePath("../AnimationPlayer")
tree_root = ExtResource("4_1bvp3") tree_root = ExtResource("4_1bvp3")
skeleton = NodePath("../MixamoAmy/Armature/Skeleton3D") skeleton = NodePath("../MixamoAmy/Armature/Skeleton3D")
parameters/AnimationBlend2Node/blend_amount = 0.24 parameters/AnimationBlend2Node/blend_amount = 0.0
[editable path="MixamoAmy"] [editable path="MixamoAmy"]

View File

@ -1,12 +1,14 @@
[gd_resource type="AnimationNodeBlendTree" load_steps=4 format=3 uid="uid://c7o0gt3li5p4g"] [gd_resource type="AnimationNodeBlendTree" load_steps=4 format=3 uid="uid://c7o0gt3li5p4b"]
[sub_resource type="AnimationNodeAnimation" id="AnimationNodeAnimation_fpiwu"] [sub_resource type="AnimationNodeAnimation" id="AnimationNodeAnimation_fpiwu"]
animation = &"Limping-InPlace" animation = &"animation_library/Limping-InPlace"
[sub_resource type="AnimationNodeAnimation" id="AnimationNodeAnimation_4sffn"] [sub_resource type="AnimationNodeAnimation" id="AnimationNodeAnimation_4sffn"]
animation = &"Walk-InPlace" animation = &"animation_library/Walk-InPlace"
[sub_resource type="AnimationNodeBlend2" id="AnimationNodeBlend2_w6plo"] [sub_resource type="AnimationNodeBlend2" id="AnimationNodeBlend2_w6plo"]
sync = true
blend_amount = 0.5
[resource] [resource]
nodes/output/position = Vector2(860, 160) nodes/output/position = Vector2(860, 160)

View File

@ -7,17 +7,18 @@
/** @class SyncTrack used for synced animation blending. /** @class SyncTrack used for synced animation blending.
* *
* A SyncTrack consists of multiple SyncInterval that are adjacent to each other. * A SyncTrack consists of multiple sync intervals that are adjacent to each other.
* *
* Important definitions: * Important definitions:
* *
* - Absolute Time: time within an animation duration in seconds. * - Absolute Time: time within an animation duration in seconds.
* - Ratio: time relative to the animations duration, e.g. 0.5 corresponds to 50% of the duration. * - Ratio: time relative to the SyncTrack duration, e.g. 0.5 corresponds to 50% of the duration.
* - SyncTime is a floating point value where the integer parts defines the SyncInterval and the * - SyncTime is a floating point value where the integer parts defines the sync interval and the
* fractional part the fraction within the interval. I.e. a SyncTime of 5.332 means it is ~33% * fractional part the fraction within the interval. I.e. a SyncTime of 5.332 means it is ~33%
* through interval 5. * through interval 5.
* *
* A SyncInterval is defined by a ratio of the starting point and the ratio of the interval's duration. * A sync interval is defined by a ratio of the starting point and the ratio of the interval's
* duration. Blended SyncTracks always have their first interval start at t = 0.0s.
*/ */
struct SyncTrack { struct SyncTrack {
static constexpr int cSyncTrackMaxIntervals = 8; static constexpr int cSyncTrackMaxIntervals = 8;
@ -119,6 +120,10 @@ struct SyncTrack {
return result; return result;
} }
/** Creates a blended SyncTrack from two input SyncTracks
*
* \note the first interval will always start at sync time 0, i.e. interval_start_ratio[0] = 0.0.
*/
static SyncTrack static SyncTrack
blend(float weight, const SyncTrack &track_A, const SyncTrack &track_B) { blend(float weight, const SyncTrack &track_A, const SyncTrack &track_B) {
assert(track_A.num_intervals == track_B.num_intervals); assert(track_A.num_intervals == track_B.num_intervals);
@ -129,17 +134,7 @@ struct SyncTrack {
result.duration = result.duration =
(1.0f - weight) * track_A.duration + weight * track_B.duration; (1.0f - weight) * track_A.duration + weight * track_B.duration;
float interval_0_offset = result.interval_start_ratio[0] = 0.f;
track_B.interval_start_ratio[0] - track_A.interval_start_ratio[0];
if (interval_0_offset > 0.5f) {
interval_0_offset = -fmodf(1.f - interval_0_offset, 1.0f);
} else if (interval_0_offset < -0.5) {
interval_0_offset = fmodf(1.f + interval_0_offset, 1.0f);
}
result.interval_start_ratio[0] = fmodf(
1.0 + (1.0f - weight) * track_A.interval_start_ratio[0] + weight * (track_A.interval_start_ratio[0] + interval_0_offset),
1.0f);
for (int i = 0; i < result.num_intervals; i++) { for (int i = 0; i < result.num_intervals; i++) {
float interval_duration_A = track_A.interval_duration_ratio[i]; float interval_duration_A = track_A.interval_duration_ratio[i];

View File

@ -201,43 +201,63 @@ void AnimationData::sample_from_animation(const Ref<Animation> &animation, const
} }
bool AnimationSamplerNode::initialize(GraphEvaluationContext &context) { bool AnimationSamplerNode::initialize(GraphEvaluationContext &context) {
SyncedAnimationNode::initialize(context);
animation = context.animation_player->get_animation(animation_name); animation = context.animation_player->get_animation(animation_name);
if (!animation.is_valid()) { if (!animation.is_valid()) {
print_error(vformat("Cannot initialize node %s: animation '%s' not found in animation player.", name, animation_name)); print_error(vformat("Cannot initialize node %s: animation '%s' not found in animation player.", name, animation_name));
return false; return false;
} }
if (animation_name == "animation_library/TestAnimationA") { node_time_info.loop_mode = animation->get_loop_mode();
// Corresponds to the walking animation
node_time_info.sync_track = SyncTrack::create_from_markers(animation->get_length(), { 0.8117, 0.314 }); // Initialize Sync Track from marker
print_line(vformat("Using hardcoded sync track for animation %s.", animation_name)); LocalVector<float> sync_markers;
} else if (animation_name == "animation_library/TestAnimationB") { int marker_index = 0;
// Corresponds to the running animation StringName marker_name = itos(marker_index);
node_time_info.sync_track = SyncTrack::create_from_markers(animation->get_length(), { 0.6256, 0.2721 }); while (animation->has_marker(marker_name)) {
print_line(vformat("Using hardcoded sync track for animation %s.", animation_name)); sync_markers.push_back(animation->get_marker_time(marker_name));
} else if (animation_name == "animation_library/TestAnimationC") { marker_index++;
// Corresponds to the limping animation marker_name = itos(marker_index);
node_time_info.sync_track = SyncTrack::create_from_markers(animation->get_length(), { 0.0674, 1.1047 });
print_line(vformat("Using hardcoded sync track for animation %s.", animation_name));
} }
node_time_info.length = animation->get_length(); if (sync_markers.size() > 0) {
node_time_info.loop_mode = Animation::LOOP_LINEAR; node_time_info.sync_track = SyncTrack::create_from_markers(animation->get_length(), sync_markers);
} else {
node_time_info.sync_track = SyncTrack::create_from_markers(animation->get_length(), { 0 });
}
return true; return true;
} }
void AnimationSamplerNode::update_time(double p_time) {
SyncedAnimationNode::update_time(p_time);
if (node_time_info.is_synced) {
// Any potential looping has already been performed in the sync-controlling node.
return;
}
if (node_time_info.loop_mode != Animation::LOOP_NONE) {
if (node_time_info.loop_mode == Animation::LOOP_LINEAR) {
if (!Math::is_zero_approx(animation->get_length())) {
node_time_info.position = Math::fposmod(node_time_info.position, static_cast<double>(animation->get_length()));
}
} else {
assert(false && !"Ping-pong looping not yet supported");
}
}
}
void AnimationSamplerNode::evaluate(GraphEvaluationContext &context, const LocalVector<AnimationData *> &inputs, AnimationData &output) { void AnimationSamplerNode::evaluate(GraphEvaluationContext &context, const LocalVector<AnimationData *> &inputs, AnimationData &output) {
assert(inputs.size() == 0); assert(inputs.size() == 0);
double sample_time = node_time_info.position;
if (node_time_info.is_synced) { if (node_time_info.is_synced) {
sample_time = node_time_info.sync_track.calc_ratio_from_sync_time(node_time_info.sync_position) * animation->get_length(); node_time_info.position = node_time_info.sync_track.calc_ratio_from_sync_time(node_time_info.sync_position) * animation->get_length();
} }
output.clear(); output.clear();
output.sample_from_animation(animation, context.skeleton_3d, sample_time); output.sample_from_animation(animation, context.skeleton_3d, node_time_info.position);
} }
void AnimationSamplerNode::set_animation(const StringName &p_name) { void AnimationSamplerNode::set_animation(const StringName &p_name) {
@ -276,7 +296,7 @@ void AnimationBlend2Node::_bind_methods() {
} }
void AnimationBlend2Node::get_parameter_list(List<PropertyInfo> *p_list) const { void AnimationBlend2Node::get_parameter_list(List<PropertyInfo> *p_list) const {
p_list->push_back(PropertyInfo(Variant::FLOAT, blend_amount, PROPERTY_HINT_RANGE, "0,1,0.01,or_less,or_greater")); p_list->push_back(PropertyInfo(Variant::FLOAT, blend_weight_pname, PROPERTY_HINT_RANGE, "0,1,0.01,or_less,or_greater"));
} }
void AnimationBlend2Node::set_parameter(const StringName &p_name, const Variant &p_value) { void AnimationBlend2Node::set_parameter(const StringName &p_name, const Variant &p_value) {
@ -290,7 +310,7 @@ Variant AnimationBlend2Node::get_parameter(const StringName &p_name) const {
} }
Variant AnimationBlend2Node::get_parameter_default_value(const StringName &p_parameter) const { Variant AnimationBlend2Node::get_parameter_default_value(const StringName &p_parameter) const {
if (p_parameter == blend_amount) { if (p_parameter == blend_weight_pname) {
return blend_weight; return blend_weight;
} }
@ -298,23 +318,34 @@ Variant AnimationBlend2Node::get_parameter_default_value(const StringName &p_par
} }
void AnimationBlend2Node::_get_property_list(List<PropertyInfo> *p_list) const { void AnimationBlend2Node::_get_property_list(List<PropertyInfo> *p_list) const {
p_list->push_back(PropertyInfo(Variant::FLOAT, blend_amount, PROPERTY_HINT_RANGE, "0,1,0.01,or_less,or_greater")); p_list->push_back(PropertyInfo(Variant::FLOAT, blend_weight_pname, PROPERTY_HINT_RANGE, "0,1,0.01,or_less,or_greater"));
p_list->push_back(PropertyInfo(Variant::BOOL, sync_pname));
} }
bool AnimationBlend2Node::_get(const StringName &p_name, Variant &r_value) const { bool AnimationBlend2Node::_get(const StringName &p_name, Variant &r_value) const {
if (p_name == blend_amount) { if (p_name == blend_weight_pname) {
r_value = blend_weight; r_value = blend_weight;
return true; return true;
} }
if (p_name == sync_pname) {
r_value = sync;
return true;
}
return false; return false;
} }
bool AnimationBlend2Node::_set(const StringName &p_name, const Variant &p_value) { bool AnimationBlend2Node::_set(const StringName &p_name, const Variant &p_value) {
if (p_name == blend_amount) { if (p_name == blend_weight_pname) {
blend_weight = p_value; blend_weight = p_value;
return true; return true;
} }
if (p_name == sync_pname) {
sync = p_value;
return true;
}
return false; return false;
} }

View File

@ -220,14 +220,12 @@ protected:
public: public:
struct NodeTimeInfo { struct NodeTimeInfo {
double length = 0.0; double delta = 0.0;
double position = 0.0; double position = 0.0;
double sync_position = 0.0; double sync_position = 0.0;
double delta = 0.0;
double sync_delta = 0.0;
bool is_synced = false; bool is_synced = false;
Animation::LoopMode loop_mode = Animation::LOOP_LINEAR; Animation::LoopMode loop_mode = Animation::LOOP_NONE;
SyncTrack sync_track; SyncTrack sync_track;
}; };
NodeTimeInfo node_time_info; NodeTimeInfo node_time_info;
@ -237,7 +235,10 @@ public:
Vector2 position; Vector2 position;
virtual ~SyncedAnimationNode() override = default; virtual ~SyncedAnimationNode() override = default;
virtual bool initialize(GraphEvaluationContext &context) { return true; } virtual bool initialize(GraphEvaluationContext &context) {
node_time_info = {};
return true;
}
virtual void activate_inputs(Vector<Ref<SyncedAnimationNode>> input_nodes) { virtual void activate_inputs(Vector<Ref<SyncedAnimationNode>> input_nodes) {
// By default, all inputs nodes are activated. // By default, all inputs nodes are activated.
@ -250,6 +251,7 @@ public:
// By default, use the SyncTrack of the first input. // By default, use the SyncTrack of the first input.
if (input_nodes.size() > 0) { if (input_nodes.size() > 0) {
node_time_info.sync_track = input_nodes[0]->node_time_info.sync_track; node_time_info.sync_track = input_nodes[0]->node_time_info.sync_track;
node_time_info.loop_mode = input_nodes[0]->node_time_info.loop_mode;
} }
} }
virtual void update_time(double p_time) { virtual void update_time(double p_time) {
@ -258,25 +260,6 @@ public:
} else { } else {
node_time_info.delta = p_time; node_time_info.delta = p_time;
node_time_info.position += p_time; node_time_info.position += p_time;
if (node_time_info.position > node_time_info.length) {
switch (node_time_info.loop_mode) {
case Animation::LOOP_NONE: {
node_time_info.position = node_time_info.length;
break;
}
case Animation::LOOP_LINEAR: {
assert(node_time_info.length > 0.0);
while (node_time_info.position > node_time_info.length) {
node_time_info.position -= node_time_info.length;
}
break;
}
case Animation::LOOP_PINGPONG: {
assert(false && !"Not yet implemented.");
break;
}
}
}
} }
} }
virtual void evaluate(GraphEvaluationContext &context, const LocalVector<AnimationData *> &input_datas, AnimationData &output_data) { virtual void evaluate(GraphEvaluationContext &context, const LocalVector<AnimationData *> &input_datas, AnimationData &output_data) {
@ -317,6 +300,7 @@ private:
Ref<Animation> animation; Ref<Animation> animation;
bool initialize(GraphEvaluationContext &context) override; bool initialize(GraphEvaluationContext &context) override;
void update_time(double p_time) override;
void evaluate(GraphEvaluationContext &context, const LocalVector<AnimationData *> &inputs, AnimationData &output) override; void evaluate(GraphEvaluationContext &context, const LocalVector<AnimationData *> &inputs, AnimationData &output) override;
protected: protected:
@ -336,7 +320,6 @@ class AnimationBlend2Node : public SyncedAnimationNode {
GDCLASS(AnimationBlend2Node, SyncedAnimationNode); GDCLASS(AnimationBlend2Node, SyncedAnimationNode);
public: public:
StringName blend_amount = PNAME("blend_amount");
float blend_weight = 0.0f; float blend_weight = 0.0f;
bool sync = true; bool sync = true;
@ -344,6 +327,17 @@ public:
inputs.push_back("Input0"); inputs.push_back("Input0");
inputs.push_back("Input1"); inputs.push_back("Input1");
} }
bool initialize(GraphEvaluationContext &context) override {
bool result = SyncedAnimationNode::initialize(context);
if (sync) {
// TODO: do we always want looping in this case or do we traverse the graph to check what's reasonable?
node_time_info.loop_mode = Animation::LOOP_LINEAR;
}
return result;
}
void activate_inputs(Vector<Ref<SyncedAnimationNode>> input_nodes) override { void activate_inputs(Vector<Ref<SyncedAnimationNode>> input_nodes) override {
for (const Ref<SyncedAnimationNode> &node : input_nodes) { for (const Ref<SyncedAnimationNode> &node : input_nodes) {
node->active = true; node->active = true;
@ -352,17 +346,28 @@ public:
node->node_time_info.is_synced = node_time_info.is_synced || sync; node->node_time_info.is_synced = node_time_info.is_synced || sync;
} }
} }
void calculate_sync_track(Vector<Ref<SyncedAnimationNode>> input_nodes) override { void calculate_sync_track(Vector<Ref<SyncedAnimationNode>> input_nodes) override {
if (node_time_info.is_synced || sync) { if (node_time_info.is_synced || sync) {
assert(input_nodes[0]->node_time_info.loop_mode == input_nodes[1]->node_time_info.loop_mode);
node_time_info.sync_track = SyncTrack::blend(blend_weight, input_nodes[0]->node_time_info.sync_track, input_nodes[1]->node_time_info.sync_track); node_time_info.sync_track = SyncTrack::blend(blend_weight, input_nodes[0]->node_time_info.sync_track, input_nodes[1]->node_time_info.sync_track);
node_time_info.length = node_time_info.sync_track.duration;
} }
} }
void update_time(double p_delta) override { void update_time(double p_delta) override {
SyncedAnimationNode::update_time(p_delta); SyncedAnimationNode::update_time(p_delta);
if (sync && !node_time_info.is_synced) { if (sync && !node_time_info.is_synced) {
node_time_info.sync_position = node_time_info.sync_track.calc_sync_from_abs_time(node_time_info.position); if (node_time_info.loop_mode != Animation::LOOP_NONE) {
if (node_time_info.loop_mode == Animation::LOOP_LINEAR) {
if (!Math::is_zero_approx(node_time_info.sync_track.duration)) {
node_time_info.position = Math::fposmod(static_cast<float>(node_time_info.position), node_time_info.sync_track.duration);
node_time_info.sync_position = node_time_info.sync_track.calc_sync_from_abs_time(node_time_info.position);
} else {
assert(false && !"Loop mode ping-pong not yet supported");
}
}
}
} }
} }
void evaluate(GraphEvaluationContext &context, const LocalVector<AnimationData *> &inputs, AnimationData &output) override; void evaluate(GraphEvaluationContext &context, const LocalVector<AnimationData *> &inputs, AnimationData &output) override;
@ -381,6 +386,10 @@ protected:
void _get_property_list(List<PropertyInfo> *p_list) const; void _get_property_list(List<PropertyInfo> *p_list) const;
bool _get(const StringName &p_name, Variant &r_value) const; bool _get(const StringName &p_name, Variant &r_value) const;
bool _set(const StringName &p_name, const Variant &p_value); bool _set(const StringName &p_name, const Variant &p_value);
private:
StringName blend_weight_pname = PNAME("blend_amount");
StringName sync_pname = PNAME("sync");
}; };
struct BlendTreeConnection { struct BlendTreeConnection {
@ -663,6 +672,14 @@ public:
return tree_graph.find_node_index_by_name(name); return tree_graph.find_node_index_by_name(name);
} }
Ref<SyncedAnimationNode> get_node(int node_index) {
if (node_index < 0 || node_index > tree_graph.nodes.size()) {
return nullptr;
}
return tree_graph.nodes[node_index];
}
void add_node(const Ref<SyncedAnimationNode> &node) { void add_node(const Ref<SyncedAnimationNode> &node) {
if (tree_initialized) { if (tree_initialized) {
print_error("Cannot add node to BlendTree: BlendTree already initialized."); print_error("Cannot add node to BlendTree: BlendTree already initialized.");

View File

@ -66,16 +66,20 @@ TEST_CASE("[SyncedAnimationGraph][SyncTrack] Basic") {
WHEN("Blending two synctracks with weight 0.") { WHEN("Blending two synctracks with weight 0.") {
SyncTrack blended = SyncTrack::blend(0.f, track_a, track_b); SyncTrack blended = SyncTrack::blend(0.f, track_a, track_b);
THEN("Result must equal track_A") { blended.duration = track_a.duration;
REQUIRE(track_a == blended); blended.interval_start_ratio[0] = 0.0;
for (int i = 0; i < track_a.num_intervals; i++) {
CHECK(blended.interval_duration_ratio[i] == track_a.interval_duration_ratio[i]);
} }
} }
WHEN("Blending two synctracks with weight 1.") { WHEN("Blending two synctracks with weight 1.") {
SyncTrack blended = SyncTrack::blend(1.f, track_a, track_b); SyncTrack blended = SyncTrack::blend(1.f, track_a, track_b);
THEN("Result must equal track_B") { blended.duration = track_b.duration;
REQUIRE(track_b == blended); blended.interval_start_ratio[0] = 0.0;
for (int i = 0; i < track_b.num_intervals; i++) {
CHECK(blended.interval_duration_ratio[i] == track_b.interval_duration_ratio[i]);
} }
} }
} }
@ -153,16 +157,20 @@ TEST_CASE("[SyncedAnimationGraph][SyncTrack] Sync Track blending") {
WHEN("Blending two synctracks with weight 0.") { WHEN("Blending two synctracks with weight 0.") {
SyncTrack blended = SyncTrack::blend(0.f, track_a, track_b); SyncTrack blended = SyncTrack::blend(0.f, track_a, track_b);
THEN("Result must equal track_A") { blended.duration = track_a.duration;
REQUIRE(track_a == blended); blended.interval_start_ratio[0] = 0.0;
for (int i = 0; i < track_a.num_intervals; i++) {
CHECK(blended.interval_duration_ratio[i] == track_a.interval_duration_ratio[i]);
} }
} }
WHEN("Blending two synctracks with weight 1.") { WHEN("Blending two synctracks with weight 1.") {
SyncTrack blended = SyncTrack::blend(1.f, track_a, track_b); SyncTrack blended = SyncTrack::blend(1.f, track_a, track_b);
THEN("Result must equal track_B") { blended.duration = track_b.duration;
REQUIRE(track_b == blended); blended.interval_start_ratio[0] = 0.0;
for (int i = 0; i < track_b.num_intervals; i++) {
CHECK(blended.interval_duration_ratio[i] == track_b.interval_duration_ratio[i]);
} }
} }
@ -173,7 +181,7 @@ TEST_CASE("[SyncedAnimationGraph][SyncTrack] Sync Track blending") {
REQUIRE( REQUIRE(
blended.duration == (1.0f - weight) * track_a.duration + weight * track_b.duration); blended.duration == (1.0f - weight) * track_a.duration + weight * track_b.duration);
REQUIRE( REQUIRE(
blended.interval_start_ratio[0] == fmodf((1.0f - weight) * (track_a.interval_start_ratio[0] + 1.0f) + weight * (track_b.interval_start_ratio[0]), 1.0f)); blended.interval_start_ratio[0] == 0.0);
REQUIRE( REQUIRE(
blended.interval_duration_ratio[1] == (1.0f - weight) * (track_a.interval_duration_ratio[1]) + weight * (track_b.interval_duration_ratio[1])); blended.interval_duration_ratio[1] == (1.0f - weight) * (track_a.interval_duration_ratio[1]) + weight * (track_b.interval_duration_ratio[1]));
REQUIRE( REQUIRE(
@ -187,7 +195,7 @@ TEST_CASE("[SyncedAnimationGraph][SyncTrack] Sync Track blending") {
REQUIRE( REQUIRE(
blended.duration == (1.0f - weight) * track_b.duration + weight * track_a.duration); blended.duration == (1.0f - weight) * track_b.duration + weight * track_a.duration);
REQUIRE( REQUIRE(
blended.interval_start_ratio[0] == fmodf((1.0f - weight) * (track_b.interval_start_ratio[0]) + weight * (track_a.interval_start_ratio[0] + 1.0f), 1.0f)); blended.interval_start_ratio[0] == 0.0);
REQUIRE( REQUIRE(
blended.interval_duration_ratio[1] == (1.0f - weight) * (track_b.interval_duration_ratio[1]) + weight * (track_a.interval_duration_ratio[1])); blended.interval_duration_ratio[1] == (1.0f - weight) * (track_b.interval_duration_ratio[1]) + weight * (track_a.interval_duration_ratio[1]));
REQUIRE( REQUIRE(

View File

@ -15,10 +15,23 @@ struct SyncedAnimationGraphFixture {
Ref<Animation> test_animation_a; Ref<Animation> test_animation_a;
Ref<Animation> test_animation_b; Ref<Animation> test_animation_b;
Ref<Animation> test_animation_sync_a;
Ref<Animation> test_animation_sync_b;
Ref<AnimationLibrary> animation_library; Ref<AnimationLibrary> animation_library;
SyncedAnimationGraph *synced_animation_graph; SyncedAnimationGraph *synced_animation_graph;
SyncedAnimationGraphFixture() { SyncedAnimationGraphFixture() {
SyncedAnimationGraph *scene_animation_graph = dynamic_cast<SyncedAnimationGraph *>(SceneTree::get_singleton()->get_root()->find_child("SyncedAnimationGraphFixtureTestNode", true, false));
if (scene_animation_graph == nullptr) {
setup_test_scene();
}
assign_scene_variables();
}
void setup_test_scene() {
character_node = memnew(Node); character_node = memnew(Node);
character_node->set_name("CharacterNode"); character_node->set_name("CharacterNode");
SceneTree::get_singleton()->get_root()->add_child(character_node); SceneTree::get_singleton()->get_root()->add_child(character_node);
@ -38,6 +51,7 @@ struct SyncedAnimationGraphFixture {
SceneTree::get_singleton()->get_root()->add_child(player_node); SceneTree::get_singleton()->get_root()->add_child(player_node);
synced_animation_graph = memnew(SyncedAnimationGraph); synced_animation_graph = memnew(SyncedAnimationGraph);
synced_animation_graph->set_name("SyncedAnimationGraphFixtureTestNode");
SceneTree::get_singleton()->get_root()->add_child(synced_animation_graph); SceneTree::get_singleton()->get_root()->add_child(synced_animation_graph);
synced_animation_graph->set_animation_player(player_node->get_path()); synced_animation_graph->set_animation_player(player_node->get_path());
@ -51,6 +65,7 @@ struct SyncedAnimationGraphFixture {
test_animation_a->track_insert_key(track_index, 0.0, Vector3(0., 0., 0.)); test_animation_a->track_insert_key(track_index, 0.0, Vector3(0., 0., 0.));
test_animation_a->track_insert_key(track_index, 1.0, Vector3(1., 2., 3.)); test_animation_a->track_insert_key(track_index, 1.0, Vector3(1., 2., 3.));
test_animation_a->track_set_path(track_index, NodePath(vformat("%s:%s", skeleton_node->get_path().get_concatenated_names(), "Hips"))); test_animation_a->track_set_path(track_index, NodePath(vformat("%s:%s", skeleton_node->get_path().get_concatenated_names(), "Hips")));
test_animation_a->set_loop_mode(Animation::LOOP_LINEAR);
animation_library.instantiate(); animation_library.instantiate();
animation_library->add_animation("TestAnimationA", test_animation_a); animation_library->add_animation("TestAnimationA", test_animation_a);
@ -61,11 +76,67 @@ struct SyncedAnimationGraphFixture {
test_animation_b->track_insert_key(track_index, 0.0, Vector3(0., 0., 0.)); test_animation_b->track_insert_key(track_index, 0.0, Vector3(0., 0., 0.));
test_animation_b->track_insert_key(track_index, 1.0, Vector3(2., 4., 6.)); test_animation_b->track_insert_key(track_index, 1.0, Vector3(2., 4., 6.));
test_animation_b->track_set_path(track_index, NodePath(vformat("%s:%s", skeleton_node->get_path().get_concatenated_names(), "Hips"))); test_animation_b->track_set_path(track_index, NodePath(vformat("%s:%s", skeleton_node->get_path().get_concatenated_names(), "Hips")));
test_animation_b->set_loop_mode(Animation::LOOP_LINEAR);
animation_library->add_animation("TestAnimationB", test_animation_b); animation_library->add_animation("TestAnimationB", test_animation_b);
test_animation_sync_a = memnew(Animation);
track_index = test_animation_sync_a->add_track(Animation::TYPE_POSITION_3D);
CHECK(track_index == 0);
test_animation_sync_a->track_insert_key(track_index, 0.0, Vector3(0., 0., 0.));
test_animation_sync_a->track_insert_key(track_index, 0.4, Vector3(1., 2., 3.));
test_animation_sync_a->set_length(2.0);
test_animation_sync_a->track_set_path(track_index, NodePath(vformat("%s:%s", skeleton_node->get_path().get_concatenated_names(), "Hips")));
test_animation_sync_a->add_marker("0", 0.0);
test_animation_sync_a->add_marker("1", 0.4);
test_animation_sync_a->track_set_interpolation_type(track_index, Animation::INTERPOLATION_LINEAR);
test_animation_sync_a->set_loop_mode(Animation::LOOP_LINEAR);
animation_library->add_animation("TestAnimationSyncA", test_animation_sync_a);
test_animation_sync_b = memnew(Animation);
track_index = test_animation_sync_b->add_track(Animation::TYPE_POSITION_3D);
CHECK(track_index == 0);
test_animation_sync_b->track_insert_key(track_index, 0.1, Vector3(2., 4., 6.));
test_animation_sync_b->track_insert_key(track_index, 0.2, Vector3(0., 0., 0.));
test_animation_sync_b->set_length(1.0);
test_animation_sync_b->track_set_path(track_index, NodePath(vformat("%s:%s", skeleton_node->get_path().get_concatenated_names(), "Hips")));
test_animation_sync_b->add_marker("1", 0.1);
test_animation_sync_b->add_marker("0", 0.2);
test_animation_sync_b->track_set_interpolation_type(track_index, Animation::INTERPOLATION_LINEAR);
test_animation_sync_b->set_loop_mode(Animation::LOOP_LINEAR);
animation_library->add_animation("TestAnimationSyncB", test_animation_sync_b);
player_node->add_animation_library("animation_library", animation_library); player_node->add_animation_library("animation_library", animation_library);
} }
void assign_scene_variables() {
synced_animation_graph = dynamic_cast<SyncedAnimationGraph *>(SceneTree::get_singleton()->get_root()->find_child("SyncedAnimationGraphFixtureTestNode", true, false));
REQUIRE(synced_animation_graph);
character_node = (SceneTree::get_singleton()->get_root()->find_child("CharacterNode", true, false));
REQUIRE(character_node != nullptr);
skeleton_node = dynamic_cast<Skeleton3D *>((SceneTree::get_singleton()->get_root()->find_child("Skeleton", true, false)));
REQUIRE(skeleton_node != nullptr);
player_node = dynamic_cast<AnimationPlayer *>((SceneTree::get_singleton()->get_root()->find_child("AnimationPlayer", true, false)));
REQUIRE(player_node != nullptr);
skeleton_node->reset_bone_poses();
hip_bone_index = skeleton_node->find_bone("Hips");
REQUIRE(hip_bone_index > -1);
animation_library = player_node->get_animation_library("animation_library");
REQUIRE(animation_library.is_valid());
test_animation_a = animation_library->get_animation("TestAnimationA");
REQUIRE(test_animation_a.is_valid());
test_animation_b = animation_library->get_animation("TestAnimationB");
REQUIRE(test_animation_b.is_valid());
test_animation_sync_a = animation_library->get_animation("TestAnimationSyncA");
REQUIRE(test_animation_sync_a.is_valid());
test_animation_sync_b = animation_library->get_animation("TestAnimationSyncB");
REQUIRE(test_animation_sync_b.is_valid());
}
}; };
namespace TestSyncedAnimationGraph { namespace TestSyncedAnimationGraph {
@ -177,7 +248,7 @@ TEST_CASE_FIXTURE(SyncedAnimationGraphFixture, "[SceneTree][SyncedAnimationGraph
} }
} }
TEST_CASE_FIXTURE(SyncedAnimationGraphFixture, "[SceneTree][SyncedAnimationGraph] SyncedAnimationGraph with an AnimationSampler as root node") { TEST_CASE_FIXTURE(SyncedAnimationGraphFixture, "[SceneTree][SyncedAnimationGraph] SyncedAnimationGraph evaluation with an AnimationSampler as root node") {
Ref<AnimationSamplerNode> animation_sampler_node; Ref<AnimationSamplerNode> animation_sampler_node;
animation_sampler_node.instantiate(); animation_sampler_node.instantiate();
animation_sampler_node->animation_name = "animation_library/TestAnimationA"; animation_sampler_node->animation_name = "animation_library/TestAnimationA";
@ -199,7 +270,7 @@ TEST_CASE_FIXTURE(SyncedAnimationGraphFixture, "[SceneTree][SyncedAnimationGraph
CHECK(hip_bone_position.z == doctest::Approx(0.03)); CHECK(hip_bone_position.z == doctest::Approx(0.03));
} }
TEST_CASE_FIXTURE(SyncedAnimationGraphFixture, "[SceneTree][SyncedAnimationGraph][BlendTree] BlendTree with a AnimationSamplerNode connected to the output") { TEST_CASE_FIXTURE(SyncedAnimationGraphFixture, "[SceneTree][SyncedAnimationGraph][BlendTree] BlendTree evaluation with a AnimationSamplerNode connected to the output") {
Ref<SyncedBlendTree> synced_blend_tree_node; Ref<SyncedBlendTree> synced_blend_tree_node;
synced_blend_tree_node.instantiate(); synced_blend_tree_node.instantiate();
@ -229,7 +300,7 @@ TEST_CASE_FIXTURE(SyncedAnimationGraphFixture, "[SceneTree][SyncedAnimationGraph
CHECK(hip_bone_position.z == doctest::Approx(0.03)); CHECK(hip_bone_position.z == doctest::Approx(0.03));
} }
TEST_CASE_FIXTURE(SyncedAnimationGraphFixture, "[SceneTree][SyncedAnimationGraph][BlendTree][Blend2Node] BlendTree with a Blend2Node connected to the output") { TEST_CASE_FIXTURE(SyncedAnimationGraphFixture, "[SceneTree][SyncedAnimationGraph][BlendTree][Blend2Node] BlendTree evaluation with a Blend2Node connected to the output") {
Ref<SyncedBlendTree> synced_blend_tree_node; Ref<SyncedBlendTree> synced_blend_tree_node;
synced_blend_tree_node.instantiate(); synced_blend_tree_node.instantiate();
@ -250,7 +321,9 @@ TEST_CASE_FIXTURE(SyncedAnimationGraphFixture, "[SceneTree][SyncedAnimationGraph
// Blend2 // Blend2
Ref<AnimationBlend2Node> blend2_node; Ref<AnimationBlend2Node> blend2_node;
blend2_node.instantiate(); blend2_node.instantiate();
blend2_node->name = "Blend2";
blend2_node->blend_weight = 0.5; blend2_node->blend_weight = 0.5;
blend2_node->sync = false;
synced_blend_tree_node->add_node(blend2_node); synced_blend_tree_node->add_node(blend2_node);
@ -271,61 +344,108 @@ TEST_CASE_FIXTURE(SyncedAnimationGraphFixture, "[SceneTree][SyncedAnimationGraph
synced_animation_graph->set_root_animation_node(synced_blend_tree_node); synced_animation_graph->set_root_animation_node(synced_blend_tree_node);
Vector3 hip_bone_position = skeleton_node->get_bone_global_pose(hip_bone_index).origin; SUBCASE("Perform default evaluation") {
Vector3 hip_bone_position = skeleton_node->get_bone_global_pose(hip_bone_index).origin;
CHECK(hip_bone_position.x == doctest::Approx(0.0)); CHECK(hip_bone_position.x == doctest::Approx(0.0));
CHECK(hip_bone_position.y == doctest::Approx(0.0)); CHECK(hip_bone_position.y == doctest::Approx(0.0));
CHECK(hip_bone_position.z == doctest::Approx(0.0)); CHECK(hip_bone_position.z == doctest::Approx(0.0));
SceneTree::get_singleton()->process(0.5); SceneTree::get_singleton()->process(0.5);
hip_bone_position = skeleton_node->get_bone_global_pose(hip_bone_index).origin; hip_bone_position = skeleton_node->get_bone_global_pose(hip_bone_index).origin;
CHECK(hip_bone_position.x == doctest::Approx(0.75)); CHECK(hip_bone_position.x == doctest::Approx(0.75));
CHECK(hip_bone_position.y == doctest::Approx(1.5)); CHECK(hip_bone_position.y == doctest::Approx(1.5));
CHECK(hip_bone_position.z == doctest::Approx(2.25)); CHECK(hip_bone_position.z == doctest::Approx(2.25));
}
// Test saving and loading of the blend tree to a resource SUBCASE("Evaluate tree such that animations get looped") {
ResourceSaver::save(synced_blend_tree_node, "synced_blend_tree_node.tres"); Vector3 hip_bone_position = skeleton_node->get_bone_global_pose(hip_bone_index).origin;
REQUIRE(ClassDB::class_exists("AnimationSamplerNode")); CHECK(hip_bone_position.x == doctest::Approx(0.0));
CHECK(hip_bone_position.y == doctest::Approx(0.0));
CHECK(hip_bone_position.z == doctest::Approx(0.0));
// Load blend tree SceneTree::get_singleton()->process(1.2);
Ref<SyncedBlendTree> loaded_synced_blend_tree = ResourceLoader::load("synced_blend_tree_node.tres");
REQUIRE(loaded_synced_blend_tree.is_valid());
loaded_synced_blend_tree->initialize(synced_animation_graph->get_context()); hip_bone_position = skeleton_node->get_bone_global_pose(hip_bone_index).origin;
synced_animation_graph->set_root_animation_node(loaded_synced_blend_tree);
// Re-evaluate using a different time. All animation samplers will start again from 0. CHECK(hip_bone_position.x == doctest::Approx(0.3));
SceneTree::get_singleton()->process(0.2); CHECK(hip_bone_position.y == doctest::Approx(0.6));
CHECK(hip_bone_position.z == doctest::Approx(0.9));
}
hip_bone_position = skeleton_node->get_bone_global_pose(hip_bone_index).origin; SUBCASE("Evaluate synced blend") {
animation_sampler_node_a->animation_name = "animation_library/TestAnimationSyncA";
animation_sampler_node_b->animation_name = "animation_library/TestAnimationSyncB";
blend2_node->sync = true;
synced_blend_tree_node->initialize(synced_animation_graph->get_context());
CHECK(hip_bone_position.x == doctest::Approx(0.3)); REQUIRE(synced_animation_graph->get_root_animation_node().ptr() == synced_blend_tree_node.ptr());
CHECK(hip_bone_position.y == doctest::Approx(0.6));
CHECK(hip_bone_position.z == doctest::Approx(0.9));
}
TEST_CASE_FIXTURE(SyncedAnimationGraphFixture, "[SceneTree][SyncedAnimationGraph][BlendTree][Blend2Node] Serialize AnimationTree" * doctest::skip(true)) { // By blending both animations we get a SyncTrack of duration 1.5s with the following
AnimationTree *animation_tree = memnew(AnimationTree); // intervals:
// 0: 0.825s
// 1: 0.675s
// By updating by 0s we get to the start of interval 0, an update by 0.65s to the start of interval 1, and
// another update by 0.85 to the start again to interval 0.
SceneTree::get_singleton()->process(0.);
character_node->add_child(animation_tree); Vector3 hip_bone_position = skeleton_node->get_bone_global_pose(hip_bone_index).origin;
animation_tree->set_animation_player(player_node->get_path());
animation_tree->set_root_node(character_node->get_path());
Ref<AnimationNodeAnimation> animation_node_animation;
animation_node_animation.instantiate();
animation_node_animation->set_animation("TestAnimationA");
Ref<AnimationNodeBlendTree> animation_node_blend_tree; CHECK(hip_bone_position.x == doctest::Approx(0.0));
animation_node_blend_tree.instantiate(); CHECK(hip_bone_position.y == doctest::Approx(0.0));
animation_node_blend_tree->add_node("SamplerTestAnimationA", animation_node_animation, Vector2(0, 0)); CHECK(hip_bone_position.z == doctest::Approx(0.0));
animation_node_blend_tree->connect_node("output", 0, "SamplerTestAnimationA");
animation_node_blend_tree->setup_local_to_scene();
animation_tree->set_root_animation_node(animation_node_blend_tree); // By updating again by 0.825s we get loop to the start of the 0th interval where
// both TrackValues are zero.
SceneTree::get_singleton()->process(0.825);
ResourceSaver::save(animation_node_blend_tree, "animation_tree.tres"); hip_bone_position = skeleton_node->get_bone_global_pose(hip_bone_index).origin;
CHECK(hip_bone_position.x == doctest::Approx(1.5));
CHECK(hip_bone_position.y == doctest::Approx(3.0));
CHECK(hip_bone_position.z == doctest::Approx(4.5));
// By updating again by 0.675s we loop to the start of the 0th interval where both
// TrackValues are zero.
SceneTree::get_singleton()->process(0.675);
hip_bone_position = skeleton_node->get_bone_global_pose(hip_bone_index).origin;
CHECK(hip_bone_position.x == doctest::Approx(0.0));
CHECK(hip_bone_position.y == doctest::Approx(0.0));
CHECK(hip_bone_position.z == doctest::Approx(0.0));
}
SUBCASE("Save, load and evaluate the SyncedBlendTree") {
// Test saving and loading of the blend tree to a resource
ResourceSaver::save(synced_blend_tree_node, "synced_blend_tree_node.tres");
REQUIRE(ClassDB::class_exists("AnimationSamplerNode"));
// Load blend tree
Ref<SyncedBlendTree> loaded_synced_blend_tree = ResourceLoader::load("synced_blend_tree_node.tres");
REQUIRE(loaded_synced_blend_tree.is_valid());
Ref<AnimationBlend2Node> loaded_blend2_node = loaded_synced_blend_tree->get_node(loaded_synced_blend_tree->find_node_index_by_name("Blend2"));
REQUIRE(loaded_blend2_node.is_valid());
CHECK(loaded_blend2_node->sync == false);
CHECK(loaded_blend2_node->blend_weight == blend2_node->blend_weight);
loaded_synced_blend_tree->initialize(synced_animation_graph->get_context());
synced_animation_graph->set_root_animation_node(loaded_synced_blend_tree);
// Re-evaluate using a different time. All animation samplers will start again from 0.
SceneTree::get_singleton()->process(0.2);
Vector3 hip_bone_position = skeleton_node->get_bone_global_pose(hip_bone_index).origin;
CHECK(hip_bone_position.x == doctest::Approx(0.3));
CHECK(hip_bone_position.y == doctest::Approx(0.6));
CHECK(hip_bone_position.z == doctest::Approx(0.9));
}
} }
} //namespace TestSyncedAnimationGraph } //namespace TestSyncedAnimationGraph