From f1641f3ac3304e0632ad80bada9a734ccfed4d4d Mon Sep 17 00:00:00 2001 From: Martin Felis Date: Wed, 31 Dec 2025 17:16:19 +0100 Subject: [PATCH] Added SyncTrack class. --- sync_track.h | 164 +++++++++++++++++++++++++++++++++ synced_animation_node.h | 4 +- tests/test_sync_track.h | 198 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 363 insertions(+), 3 deletions(-) create mode 100644 sync_track.h create mode 100644 tests/test_sync_track.h diff --git a/sync_track.h b/sync_track.h new file mode 100644 index 0000000..63852ee --- /dev/null +++ b/sync_track.h @@ -0,0 +1,164 @@ +#pragma once + +#include "core/templates/local_vector.h" + +#include +#include + +/** @class SyncTrack used for synced animation blending. + * + * A SyncTrack consists of multiple SyncInterval that are adjacent to each other. + * + * Important definitions: + * + * - 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. + * - SyncTime is a floating point value where the integer parts defines the SyncInterval and the + * fractional part the fraction within the interval. I.e. a SyncTime of 5.332 means it is ~33% + * through interval 5. + * + * A SyncInterval is defined by a ratio of the starting point and the ratio of the interval's duration. + */ +struct SyncTrack { + static constexpr int cSyncTrackMaxIntervals = 8; + + SyncTrack() : + duration(0.f), num_intervals(1) { + for (int i = 0; i < cSyncTrackMaxIntervals; i++) { + interval_start_ratio[i] = 0.f; + interval_duration_ratio[i] = 0.f; + } + + interval_duration_ratio[0] = 1.0f; + } + + float duration = 1.0; + int num_intervals = 1; + float interval_start_ratio + [cSyncTrackMaxIntervals]; //< Starting time of interval in absolute time. + float interval_duration_ratio[cSyncTrackMaxIntervals]; //< + + float calc_sync_from_abs_time(float abs_time) const { + for (int i = 0; i < num_intervals; i++) { + float query_abs_time = abs_time; + float interval_start = interval_start_ratio[i] * duration; + float interval_end = + interval_start + interval_duration_ratio[i] * duration; + + if (query_abs_time < interval_start) { + query_abs_time += duration; + } + if (query_abs_time >= interval_start && query_abs_time < interval_end) { + return float(i) + (query_abs_time - interval_start) / (interval_end - interval_start); + } + } + + assert(false && "Invalid absolute time"); + return -1.f; + } + + float calc_ratio_from_sync_time(float sync_time) const { + float interval_ratio = fmodf(sync_time, 1.0f); + int interval = int(sync_time - interval_ratio); + + return fmodf( + interval_start_ratio[interval] + interval_duration_ratio[interval] * interval_ratio, + 1.0f); + } + + bool operator==(const SyncTrack &other) const { + bool result = duration == other.duration && num_intervals == other.num_intervals; + + if (!result) { + return false; + } + + for (int i = 0; i < num_intervals; i++) { + if ((fabsf(interval_start_ratio[i] - other.interval_start_ratio[i]) > 1.0e-5) || (fabsf(interval_duration_ratio[i] - other.interval_duration_ratio[i]) > 1.0e-5)) { + return false; + } + } + + return true; + } + + /** Constructs SyncTrack from markers. + * + * Markers are specified in absolute time and must be >= 0 and <= duration. They define the + * start of the interval. The last marker is implicitly the (possibly looped) first marker. + */ + static SyncTrack create_from_markers( + float duration, + const LocalVector &markers) { + assert(markers.size() > 0); + assert(markers.size() < cSyncTrackMaxIntervals); + + SyncTrack result; + result.duration = duration; + result.num_intervals = markers.size(); + + for (unsigned int i = 0; i < markers.size(); i++) { + assert(markers[i] >= 0.f && markers[i] <= duration); + + int end_index = i == (markers.size() - 1) ? 0 : i + 1; + + float interval_start = markers[i]; + float interval_end = markers[end_index]; + + if (interval_end == interval_start) { + interval_end = interval_start + duration; + } else if (interval_end < interval_start) { + interval_end += duration; + } + + result.interval_start_ratio[i] = interval_start / duration; + result.interval_duration_ratio[i] = + (interval_end - interval_start) / duration; + } + + return result; + } + + static SyncTrack + blend(float weight, const SyncTrack &track_A, const SyncTrack &track_B) { + assert(track_A.num_intervals == track_B.num_intervals); + + SyncTrack result; + result.num_intervals = track_A.num_intervals; + + result.duration = + (1.0f - weight) * track_A.duration + weight * track_B.duration; + + float interval_0_offset = + 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++) { + float interval_duration_A = track_A.interval_duration_ratio[i]; + float interval_duration_B = track_B.interval_duration_ratio[i]; + result.interval_duration_ratio[i] = + (1.0f - weight) * interval_duration_A + weight * interval_duration_B; + + if (i < cSyncTrackMaxIntervals) { + result.interval_start_ratio[i + 1] = + result.interval_start_ratio[i] + result.interval_duration_ratio[i]; + if (result.interval_start_ratio[i + 1] > 1.0f) { + result.interval_start_ratio[i + 1] = + fmodf(result.interval_start_ratio[i + 1], 1.0f); + } + } + } + + assert(result.num_intervals < cSyncTrackMaxIntervals); + + return result; + } +}; \ No newline at end of file diff --git a/synced_animation_node.h b/synced_animation_node.h index 801bbf2..ee0fb29 100644 --- a/synced_animation_node.h +++ b/synced_animation_node.h @@ -4,6 +4,7 @@ #include "core/io/resource.h" #include "scene/3d/skeleton_3d.h" +#include "sync_track.h" #include @@ -192,9 +193,6 @@ protected: } }; -struct SyncTrack { -}; - struct GraphEvaluationContext { AnimationPlayer *animation_player = nullptr; Skeleton3D *skeleton_3d = nullptr; diff --git a/tests/test_sync_track.h b/tests/test_sync_track.h new file mode 100644 index 0000000..aa72fcc --- /dev/null +++ b/tests/test_sync_track.h @@ -0,0 +1,198 @@ +#pragma once + +#include "../sync_track.h" + +#include "tests/test_macros.h" + +namespace TestSyncedAnimationGraph { + +TEST_CASE("[SyncedAnimationGraph][SyncTrack] Basic") { + SyncTrack track_a; + track_a.num_intervals = 2; + track_a.duration = 2.0; + track_a.interval_start_ratio[0] = 0.f; + track_a.interval_duration_ratio[0] = 0.7; + track_a.interval_start_ratio[1] = 0.7f; + track_a.interval_duration_ratio[1] = 0.3; + + SyncTrack track_b; + track_b.num_intervals = 2; + track_b.duration = 1.5; + track_b.interval_start_ratio[0] = 0.0f; + track_b.interval_duration_ratio[0] = 0.6; + track_b.interval_start_ratio[1] = 0.6f; + track_b.interval_duration_ratio[1] = 0.4; + + WHEN("Calculating sync time of track_B at 0.5 duration") { + float sync_time_at_0_75 = + track_b.calc_sync_from_abs_time(0.5 * track_b.duration); + REQUIRE(sync_time_at_0_75 == doctest::Approx(0.83333)); + } + + WHEN("Calculating sync time of track_B at 0.6 duration") { + float sync_time_at_0_6 = + track_b.calc_sync_from_abs_time(0.6 * track_b.duration); + REQUIRE(sync_time_at_0_6 == doctest::Approx(1.0)); + } + + WHEN("Calculating sync time of track_B at 0.7 duration") { + float sync_time_at_0_7 = + track_b.calc_sync_from_abs_time(0.7 * track_b.duration); + REQUIRE(sync_time_at_0_7 == doctest::Approx(1.25)); + } + + WHEN("Calculating sync time of track_B at 0.0 duration") { + float sync_time_at_1_0 = + track_b.calc_sync_from_abs_time(0.0 * track_b.duration); + REQUIRE(sync_time_at_1_0 == doctest::Approx(0.0)); + } + + WHEN("Calculating sync time of track_B at 1.0 duration") { + float sync_time_at_1_0 = + track_b.calc_sync_from_abs_time(0.9999 * track_b.duration); + REQUIRE(sync_time_at_1_0 == doctest::Approx(2.0).epsilon(0.001f)); + } + + WHEN("Calculating ratio from sync time on track_A at 0.83333") { + float ratio = track_a.calc_ratio_from_sync_time(0.83333333); + REQUIRE(ratio == doctest::Approx(0.5833333)); + } + + WHEN("Calculating ratio from sync time on track_A at 0.83333") { + float ratio = track_a.calc_ratio_from_sync_time(1.25); + REQUIRE(ratio == doctest::Approx(0.775)); + } + + WHEN("Blending two synctracks with weight 0.") { + SyncTrack blended = SyncTrack::blend(0.f, track_a, track_b); + + THEN("Result must equal track_A") { + REQUIRE(track_a == blended); + } + } + + WHEN("Blending two synctracks with weight 1.") { + SyncTrack blended = SyncTrack::blend(1.f, track_a, track_b); + + THEN("Result must equal track_B") { + REQUIRE(track_b == blended); + } + } +} + +TEST_CASE("[SyncedAnimationGraph][SyncTrack] Create Sync Track from markers") { + SyncTrack track = SyncTrack::create_from_markers(2.0f, { 0.9f, 0.2f }); + + WHEN("Querying Ratios") { + CHECK(track.interval_start_ratio[0] == doctest::Approx(0.45f)); + CHECK(track.interval_duration_ratio[0] == doctest::Approx(0.65f)); + + CHECK(track.interval_start_ratio[1] == doctest::Approx(0.1f)); + CHECK(track.interval_duration_ratio[1] == doctest::Approx(0.35f)); + + WHEN("Querying ratio at sync time at 0.001") { + float ratio = track.calc_ratio_from_sync_time(0.0001f); + CHECK(ratio == doctest::Approx(0.45).epsilon(0.001)); + } + + WHEN("Querying ratio at sync time at 0.9999") { + float ratio = track.calc_ratio_from_sync_time(0.9999f); + CHECK(ratio == doctest::Approx(0.1).epsilon(0.001)); + } + + WHEN("Querying ratio at sync time at 1.001") { + float ratio = track.calc_ratio_from_sync_time(1.0001f); + CHECK(ratio == doctest::Approx(0.1).epsilon(0.001)); + } + + WHEN("Querying ratio at sync time at 1.9999") { + float ratio = track.calc_ratio_from_sync_time(1.9999f); + CHECK(ratio == doctest::Approx(0.45).epsilon(0.001)); + } + } + + WHEN("Querying SyncTime from Absolute Time") { + WHEN("Querying absolute time at 0.9001s") { + float sync_time = track.calc_sync_from_abs_time(0.9001f); + CHECK(sync_time == doctest::Approx(0.0).epsilon(0.001)); + } + + WHEN("Querying absolute time at 0.2001s") { + float sync_time = track.calc_sync_from_abs_time(0.2001f); + CHECK(sync_time == doctest::Approx(1.0).epsilon(0.001)); + } + + WHEN("Querying absolute time at 0.8999s") { + float sync_time = track.calc_sync_from_abs_time(0.8999f); + CHECK(sync_time == doctest::Approx(1.999).epsilon(0.001)); + } + + WHEN("Querying absolute time at 1.9999s") { + float sync_time = track.calc_sync_from_abs_time(1.9999f); + CHECK(sync_time == doctest::Approx(0.84615384).epsilon(0.001)); + } + } +} + +TEST_CASE("[SyncedAnimationGraph][SyncTrack] Sync Track blending") { + SyncTrack track_a = SyncTrack::create_from_markers(2.0, { 0., 0.6, 1.8 }); + SyncTrack track_b = SyncTrack::create_from_markers(1.5f, { 1.05, 1.35, 0.3 }); + + WHEN("Calculating A's durations") { + CHECK(track_a.interval_duration_ratio[0] == doctest::Approx(0.3)); + CHECK(track_a.interval_duration_ratio[1] == doctest::Approx(0.6)); + CHECK(track_a.interval_duration_ratio[2] == doctest::Approx(0.1)); + } + + WHEN("Calculating B's durations") { + CHECK(track_b.interval_duration_ratio[0] == doctest::Approx(0.2)); + CHECK(track_b.interval_duration_ratio[1] == doctest::Approx(0.3)); + CHECK(track_b.interval_duration_ratio[2] == doctest::Approx(0.5)); + } + + WHEN("Blending two synctracks with weight 0.") { + SyncTrack blended = SyncTrack::blend(0.f, track_a, track_b); + + THEN("Result must equal track_A") { + REQUIRE(track_a == blended); + } + } + + WHEN("Blending two synctracks with weight 1.") { + SyncTrack blended = SyncTrack::blend(1.f, track_a, track_b); + + THEN("Result must equal track_B") { + REQUIRE(track_b == blended); + } + } + + WHEN("Blending with weight 0.2") { + float weight = 0.2f; + SyncTrack blended = SyncTrack::blend(weight, track_a, track_b); + + REQUIRE( + blended.duration == (1.0f - weight) * track_a.duration + weight * track_b.duration); + 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)); + REQUIRE( + blended.interval_duration_ratio[1] == (1.0f - weight) * (track_a.interval_duration_ratio[1]) + weight * (track_b.interval_duration_ratio[1])); + REQUIRE( + blended.interval_duration_ratio[2] == (1.0f - weight) * (track_a.interval_duration_ratio[2]) + weight * (track_b.interval_duration_ratio[2])); + } + + WHEN("Inverted blending with weight 0.2") { + float weight = 0.2f; + SyncTrack blended = SyncTrack::blend(weight, track_b, track_a); + + REQUIRE( + blended.duration == (1.0f - weight) * track_b.duration + weight * track_a.duration); + 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)); + REQUIRE( + blended.interval_duration_ratio[1] == (1.0f - weight) * (track_b.interval_duration_ratio[1]) + weight * (track_a.interval_duration_ratio[1])); + REQUIRE( + blended.interval_duration_ratio[2] == (1.0f - weight) * (track_b.interval_duration_ratio[2]) + weight * (track_a.interval_duration_ratio[2])); + } +} + +} //namespace TestSyncedAnimationGraph \ No newline at end of file