-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathAnimationMerger.cpp
More file actions
2434 lines (2235 loc) · 110 KB
/
Copy pathAnimationMerger.cpp
File metadata and controls
2434 lines (2235 loc) · 110 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#include "AnimationMerger.h"
#include "AutoRig.h"
#include "MotionInbetween.h"
#include <OgreSkeleton.h>
#include <OgreSkeletonInstance.h>
#include <OgreAnimation.h>
#include <OgreKeyFrame.h>
#include <QSet>
#include <QMap>
#include <QRegularExpression>
#include <cctype>
#include <unordered_map>
#include <functional>
#include <algorithm>
#include <limits>
#include <map>
#include <vector>
#include <cmath>
// Registry: skeleton name → up-axis (1=Y-up, 2=Z-up).
// Populated by AnimationMerger::registerSkeletonUpAxis() at import time.
static QMap<QString, int> s_skeletonUpAxis;
// #854: last-applied arm-space angle, tracked PER SKELETON INSTANCE (not a
// process-global map — that pollutes across entities and across tests that
// share a process). Stored on the skeleton's first bone via UserObjectBindings
// under a per-animation key, so it lives and dies with the skeleton and is
// naturally isolated. Session-scoped only; export bakes the final keyframes.
namespace {
std::string armSpaceKey(const std::string& animName)
{
return "qtme.armspace." + animName;
}
float getStoredArmSpace(Ogre::Skeleton* skel, const std::string& animName)
{
if (!skel || skel->getNumBones() == 0) return 0.0f;
const Ogre::Any& a = skel->getBone(0)->getUserObjectBindings()
.getUserAny(armSpaceKey(animName));
return a.has_value() ? Ogre::any_cast<float>(a) : 0.0f;
}
void setStoredArmSpace(Ogre::Skeleton* skel, const std::string& animName,
float degrees)
{
if (!skel || skel->getNumBones() == 0) return;
skel->getBone(0)->getUserObjectBindings().setUserAny(
armSpaceKey(animName), Ogre::Any(degrees));
}
} // namespace
void AnimationMerger::registerSkeletonUpAxis(const std::string& name, int upAxis) {
s_skeletonUpAxis[QString::fromStdString(name)] = upAxis;
}
static int lookupUpAxis(const std::string& name) {
return s_skeletonUpAxis.value(QString::fromStdString(name), 1); // default Y-up
}
// Remove common noise tokens from animation names before slugifying.
// e.g. "Armature|mixamo.com|Layer0" → "Armature|Layer0"
// e.g. "Armature|unreal_take|Layer0" → "Armature|Layer0"
// e.g. "Unreal Take" (UE FBX take name, space variant) → ""
static QString cleanAnimNoise(const QString& name)
{
QString s = name;
s.replace(QRegularExpression("\\|?mixamo\\.com\\|?"), "|");
// Match both "unreal_take" (underscore) and "Unreal Take" (space) — UE FBX exports
s.replace(QRegularExpression("\\|?unreal[_ ]take\\|?", QRegularExpression::CaseInsensitiveOption), "|");
s.replace(QRegularExpression("\\|\\|+"), "|");
if (s.startsWith('|')) s.remove(0, 1);
if (s.endsWith('|')) s.chop(1);
return s;
}
// Convert a name to a slug: lowercase, non-alphanumeric → underscore, collapsed, trimmed.
// e.g. "Hip Hop Dancing.fbx" → "hip_hop_dancing_fbx"
static QString slugify(const QString& name)
{
QString s = name.toLower();
s.replace(QRegularExpression("[^a-z0-9]+"), "_");
s.replace(QRegularExpression("_+"), "_");
if (s.startsWith('_')) s.remove(0, 1);
if (s.endsWith('_')) s.chop(1);
return s;
}
// Build a clean animation name. Only prepends the node/file prefix when the
// animation name is generic (e.g. "mixamo.com" → use prefix). If the animation
// already has a meaningful name (e.g. "jump", "idle"), just clean and return it.
static QString buildAnimName(const QString& prefix, const QString& animName)
{
QString slugPrefix = slugify(prefix);
QString cleanAnim = cleanAnimNoise(animName);
QString slugAnim = slugify(cleanAnim);
// Both empty — shouldn't happen, but guard against it
if (slugAnim.isEmpty() && slugPrefix.isEmpty())
return QStringLiteral("animation");
// If after cleanup the name is empty or just "mixamo_com" residue,
// use the node/file name as the animation name
if (slugAnim.isEmpty())
return slugPrefix;
// Otherwise keep the meaningful animation name as-is
return slugAnim;
}
// Deduplicate a name against an existing set, appending _2, _3, etc. if needed.
// Intentional numeric suffixes (e.g. "mm_attack_03") are preserved when the
// name is unique. _N stripping only kicks in when the name already collides,
// so repeated merges produce jump, jump_2, jump_3 (not jump_2_2, jump_2_2_2).
static QString deduplicateName(const QString& desired, QSet<QString>& existingNames)
{
// If the desired name is free, use it as-is (preserve intentional _N suffixes).
if (!existingNames.contains(desired)) {
existingNames.insert(desired);
return desired;
}
// Collision — strip any trailing _N to find the canonical base, then re-suffix.
QString baseName = desired;
QRegularExpression trailingSuffix("_(\\d+)$");
auto match = trailingSuffix.match(baseName);
if (match.hasMatch())
baseName = baseName.left(match.capturedStart());
int suffix = 2;
QString candidate;
do {
candidate = baseName + "_" + QString::number(suffix++);
} while (existingNames.contains(candidate));
existingNames.insert(candidate);
return candidate;
}
// Copy all animations from srcSkel into baseSkel, remapping bone handles by name.
// This bypasses Ogre's _mergeSkeletonAnimations hierarchy check, which rejects skeletons
// that are structurally compatible (same bone names) but differ in their root chain
// (e.g. a mesh skeleton wrapping 'root' under a mesh-name bone that the animation lacks).
//
// srcUpAxis / baseUpAxis: coordinate-system up-axis (1=Y-up, 2=Z-up).
// When the source was exported from a Z-up tool (e.g. Unreal Engine) and the base
// skeleton is Y-up (e.g. Mixamo), needsZupToYup converts the keyframe data.
//
// Additionally, AnimationProcessor stores translations in bone-local space (pre-multiplied
// by the inverse binding-pose orientation). If the source and target skeletons have
// different binding-pose orientations for the same bone — e.g. an animation-only FBX has
// identity root while a mesh FBX has a baked R_x(+90°) root — the stored delta must be
// re-expressed in the target bone's local space.
// We apply: corrected = (q_src⁻¹ * q_dst) * stored
static void mergeAnimationsByName(Ogre::Skeleton* baseSkel, const Ogre::Skeleton* srcSkel,
int srcUpAxis = 1, int baseUpAxis = 1)
{
const bool needsZupToYup = (srcUpAxis == 2 && baseUpAxis == 1);
// Z-up (Assimp/FBX after ConvertToLeftHanded) → Y-up (Ogre) for translations:
// R_x(-90°): (x,y,z) → (x, z, -y)
// Rotations are stored in bone-local space and pass through unchanged —
// the baked dest bone orientation provides the correct world-space result.
// Build name→handle map for the base skeleton
std::unordered_map<std::string, unsigned short> baseHandleByName;
for (unsigned short i = 0; i < baseSkel->getNumBones(); ++i)
baseHandleByName[baseSkel->getBone(i)->getName()] = i;
unsigned short numAnims = srcSkel->getNumAnimations();
for (unsigned short a = 0; a < numAnims; ++a)
{
const Ogre::Animation* srcAnim = srcSkel->getAnimation(a);
Ogre::Animation* dstAnim = baseSkel->createAnimation(srcAnim->getName(), srcAnim->getLength());
dstAnim->setInterpolationMode(srcAnim->getInterpolationMode());
dstAnim->setRotationInterpolationMode(srcAnim->getRotationInterpolationMode());
for (const auto& [srcHandle, srcTrack] : srcAnim->_getNodeTrackList())
{
if (srcHandle >= srcSkel->getNumBones())
continue;
const std::string& boneName = srcSkel->getBone(srcHandle)->getName();
auto it = baseHandleByName.find(boneName);
if (it == baseHandleByName.end())
continue; // bone not in base — skip track
unsigned short baseHandle = it->second;
auto* dstTrack = dstAnim->createNodeTrack(baseHandle);
dstTrack->setAssociatedNode(baseSkel->getBone(baseHandle));
dstTrack->setUseShortestRotationPath(srcTrack->getUseShortestRotationPath());
// Per-bone binding-pose orientation correction.
// AnimationProcessor stores translate/rotate keyframes in the source bone's
// local space (divided by the source bone's binding-pose orientation).
// If source and target have different binding poses for this bone, re-express
// the stored values in the target bone's local space:
// corrected = (q_dst⁻¹ * q_src) * stored
const Ogre::Quaternion q_src = srcSkel->getBone(srcHandle)->getOrientation();
const Ogre::Quaternion q_dst = baseSkel->getBone(baseHandle)->getOrientation();
const Ogre::Quaternion boneCorrection = q_src.Inverse() * q_dst;
const bool needsBoneCorrection = !boneCorrection.equals(Ogre::Quaternion::IDENTITY, Ogre::Radian(1e-4f));
for (unsigned short k = 0; k < srcTrack->getNumKeyFrames(); ++k)
{
const auto* kf = srcTrack->getNodeKeyFrame(k);
auto* dstKf = dstTrack->createNodeKeyFrame(kf->getTime());
Ogre::Vector3 t = kf->getTranslate();
Ogre::Quaternion r = kf->getRotation();
// Step 1: correct translation for binding-pose orientation mismatch.
// Translations are stored in bone-local space (AnimationProcessor divides by
// the source bone's binding-pose orientation). If the target bone has a
// different binding-pose orientation, re-express the vector in that space.
// Rotations do NOT need this correction — the target binding pose already
// provides the equivalent compensation via the scene-node/skeleton setup.
if (needsBoneCorrection) {
t = boneCorrection * t;
}
// Step 2: convert coordinate system (Z-up source → Y-up base).
// R_x(-90°) maps (x,y,z) → (x, z, -y): Z-up axis becomes Y-up.
// Rotations pass through unchanged — bone-local storage makes them
// self-consistent once the dest binding pose is baked to Y-up.
if (needsZupToYup) {
t = Ogre::Vector3(t.x, t.z, -t.y);
}
dstKf->setTranslate(t);
dstKf->setRotation(r);
dstKf->setScale(kf->getScale());
}
}
}
}
void AnimationMerger::renameAnimation(Ogre::Skeleton* skel,
const std::string& oldName,
const std::string& newName)
{
if (oldName == newName || !skel->hasAnimation(oldName))
return;
Ogre::Animation* oldAnim = skel->getAnimation(oldName);
Ogre::Animation* newAnim = skel->createAnimation(newName, oldAnim->getLength());
newAnim->setInterpolationMode(oldAnim->getInterpolationMode());
newAnim->setRotationInterpolationMode(oldAnim->getRotationInterpolationMode());
// Copy all node tracks
for (const auto& [handle, srcTrack] : oldAnim->_getNodeTrackList())
{
auto* dstTrack = newAnim->createNodeTrack(handle);
if (srcTrack->getAssociatedNode())
dstTrack->setAssociatedNode(srcTrack->getAssociatedNode());
dstTrack->setUseShortestRotationPath(srcTrack->getUseShortestRotationPath());
for (unsigned short k = 0; k < srcTrack->getNumKeyFrames(); ++k)
{
const auto* kf = srcTrack->getNodeKeyFrame(k);
auto* dstKf = dstTrack->createNodeKeyFrame(kf->getTime());
dstKf->setTranslate(kf->getTranslate());
dstKf->setRotation(kf->getRotation());
dstKf->setScale(kf->getScale());
}
}
// #854: carry the arm-space applied-angle over to the new name — the
// widened keyframes were copied above, so the tracked angle must follow
// or currentArmSpace() would report 0 for the renamed clip and the next
// slider drag would compute a wrong (absolute-from-0) delta.
migrateArmSpaceKey(skel, oldName, newName);
skel->removeAnimation(oldName);
}
void AnimationMerger::migrateArmSpaceKey(Ogre::Skeleton* skel,
const std::string& oldAnim,
const std::string& newAnim)
{
if (!skel || oldAnim == newAnim || skel->getNumBones() == 0) return;
auto& uob = skel->getBone(0)->getUserObjectBindings();
const Ogre::Any& a = uob.getUserAny(armSpaceKey(oldAnim));
if (a.has_value()) {
setStoredArmSpace(skel, newAnim, Ogre::any_cast<float>(a));
uob.eraseUserAny(armSpaceKey(oldAnim));
}
}
int AnimationMerger::resampleAnimation(Ogre::Skeleton* skel,
const std::string& animName,
int targetKeyframes)
{
if (!skel || !skel->hasAnimation(animName) || targetKeyframes < 2)
return 0;
Ogre::Animation* srcAnim = skel->getAnimation(animName);
float length = srcAnim->getLength();
// Count original keyframes across all tracks (use max track keyframe count)
int originalMaxKeyframes = 0;
for (const auto& [handle, track] : srcAnim->_getNodeTrackList())
{
int numKf = static_cast<int>(track->getNumKeyFrames());
if (numKf > originalMaxKeyframes)
originalMaxKeyframes = numKf;
}
// Collect track data: for each track, evaluate interpolated T/R/S at N evenly-spaced times
struct TrackData {
unsigned short handle;
Ogre::Node* associatedNode;
bool useShortestPath;
struct KeyframeData {
float time;
Ogre::Vector3 translate;
Ogre::Quaternion rotation;
Ogre::Vector3 scale;
};
std::vector<KeyframeData> keyframes;
};
std::vector<TrackData> tracks;
for (const auto& [handle, srcTrack] : srcAnim->_getNodeTrackList())
{
TrackData td;
td.handle = handle;
td.associatedNode = srcTrack->getAssociatedNode();
td.useShortestPath = srcTrack->getUseShortestRotationPath();
for (int i = 0; i < targetKeyframes; ++i)
{
float t = (targetKeyframes > 1)
? (static_cast<float>(i) * length / static_cast<float>(targetKeyframes - 1))
: 0.0f;
Ogre::TransformKeyFrame interpKf(nullptr, t);
srcTrack->getInterpolatedKeyFrame(t, &interpKf);
td.keyframes.push_back({
t,
interpKf.getTranslate(),
interpKf.getRotation(),
interpKf.getScale()
});
}
tracks.push_back(std::move(td));
}
// Save animation properties
float animLength = srcAnim->getLength();
auto interpMode = srcAnim->getInterpolationMode();
auto rotInterpMode = srcAnim->getRotationInterpolationMode();
// Remove old animation and create new one with same name
skel->removeAnimation(animName);
Ogre::Animation* newAnim = skel->createAnimation(animName, animLength);
newAnim->setInterpolationMode(interpMode);
newAnim->setRotationInterpolationMode(rotInterpMode);
// Recreate tracks with resampled keyframes
for (const auto& td : tracks)
{
auto* newTrack = newAnim->createNodeTrack(td.handle);
if (td.associatedNode)
newTrack->setAssociatedNode(td.associatedNode);
newTrack->setUseShortestRotationPath(td.useShortestPath);
for (const auto& kfData : td.keyframes)
{
auto* kf = newTrack->createNodeKeyFrame(kfData.time);
kf->setTranslate(kfData.translate);
kf->setRotation(kfData.rotation);
kf->setScale(kfData.scale);
}
}
return originalMaxKeyframes - targetKeyframes;
}
int AnimationMerger::decimateAnimation(Ogre::Skeleton* skel,
const std::string& animName,
int step)
{
if (!skel || !skel->hasAnimation(animName) || step < 2)
return 0;
Ogre::Animation* srcAnim = skel->getAnimation(animName);
// Collect track data: for each track, keep only keyframes at indices 0, step, 2*step, ... and the last
struct TrackData {
unsigned short handle;
Ogre::Node* associatedNode;
bool useShortestPath;
struct KeyframeData {
float time;
Ogre::Vector3 translate;
Ogre::Quaternion rotation;
Ogre::Vector3 scale;
};
std::vector<KeyframeData> keyframes;
int originalCount;
};
std::vector<TrackData> tracks;
int totalRemoved = 0;
for (const auto& [handle, srcTrack] : srcAnim->_getNodeTrackList())
{
TrackData td;
td.handle = handle;
td.associatedNode = srcTrack->getAssociatedNode();
td.useShortestPath = srcTrack->getUseShortestRotationPath();
td.originalCount = static_cast<int>(srcTrack->getNumKeyFrames());
int numKf = td.originalCount;
for (int i = 0; i < numKf; ++i)
{
bool keep = (i % step == 0) || (i == numKf - 1);
if (keep)
{
const auto* kf = srcTrack->getNodeKeyFrame(static_cast<unsigned short>(i));
td.keyframes.push_back({
kf->getTime(),
kf->getTranslate(),
kf->getRotation(),
kf->getScale()
});
}
}
totalRemoved += (td.originalCount - static_cast<int>(td.keyframes.size()));
tracks.push_back(std::move(td));
}
// Save animation properties
float animLength = srcAnim->getLength();
auto interpMode = srcAnim->getInterpolationMode();
auto rotInterpMode = srcAnim->getRotationInterpolationMode();
// Remove old and create new
skel->removeAnimation(animName);
Ogre::Animation* newAnim = skel->createAnimation(animName, animLength);
newAnim->setInterpolationMode(interpMode);
newAnim->setRotationInterpolationMode(rotInterpMode);
for (const auto& td : tracks)
{
auto* newTrack = newAnim->createNodeTrack(td.handle);
if (td.associatedNode)
newTrack->setAssociatedNode(td.associatedNode);
newTrack->setUseShortestRotationPath(td.useShortestPath);
for (const auto& kfData : td.keyframes)
{
auto* kf = newTrack->createNodeKeyFrame(kfData.time);
kf->setTranslate(kfData.translate);
kf->setRotation(kfData.rotation);
kf->setScale(kfData.scale);
}
}
return totalRemoved;
}
// ---------------------------------------------------------------------------
// Redundant-keyframe simplification (tolerance-based, preserves sharp keys).
//
// A key K_i is "redundant" when the curve evaluated at K_i.time without that
// key — i.e. lerp/slerp between K_{i-1} and K_{i+1} — matches K_i within the
// configured tolerance. Walks each track once and removes redundant keys
// iteratively (a key adjacent to a removed key may become redundant itself
// after removal). First and last keyframes are always preserved.
//
// Mixamo-style clips bake one key per frame per bone with smooth motion
// between keys, so tolerance-based removal typically eliminates 60–80% of
// keys without visible drift.
// ---------------------------------------------------------------------------
namespace {
struct SimpleKey {
float time;
Ogre::Vector3 translate;
Ogre::Quaternion rotation;
Ogre::Vector3 scale;
};
// Choose the equivalent quaternion (q or -q) that lies on the same hemisphere
// as `ref`. Quaternion antipodes represent the same rotation but slerp/dot
// behave incorrectly across the hemisphere boundary.
static Ogre::Quaternion alignedTo(const Ogre::Quaternion& q, const Ogre::Quaternion& ref)
{
return (q.Dot(ref) < 0.0f) ? Ogre::Quaternion(-q.w, -q.x, -q.y, -q.z) : q;
}
static bool keyIsRedundant(const SimpleKey& prev,
const SimpleKey& cur,
const SimpleKey& next,
const AnimationMerger::SimplifyTolerances& tol)
{
// Degenerate: zero-width interval — the middle key cannot affect playback,
// so treat it as redundant.
const float span = next.time - prev.time;
if (span <= 1e-7f)
return true;
const float t = (cur.time - prev.time) / span;
if (t <= 0.0f || t >= 1.0f)
return false;
// Translation: linear lerp.
const Ogre::Vector3 lerpT = prev.translate + (next.translate - prev.translate) * t;
if ((lerpT - cur.translate).length() > tol.translation)
return false;
// Scale: linear lerp.
const Ogre::Vector3 lerpS = prev.scale + (next.scale - prev.scale) * t;
if ((lerpS - cur.scale).length() > tol.scale)
return false;
// Rotation: slerp on hemisphere-aligned quaternions, then compare with the
// angular-distance metric (acos|dot|, doubled to give the rotation angle).
const Ogre::Quaternion qPrev = prev.rotation;
const Ogre::Quaternion qNext = alignedTo(next.rotation, qPrev);
const Ogre::Quaternion qCur = alignedTo(cur.rotation, qPrev);
const Ogre::Quaternion slerpR = Ogre::Quaternion::Slerp(t, qPrev, qNext, /*shortestPath*/ true);
float dot = std::abs(slerpR.Dot(qCur));
if (dot > 1.0f) dot = 1.0f;
const float angleDeg = Ogre::Math::ACos(dot).valueDegrees() * 2.0f;
if (angleDeg > tol.rotationDeg)
return false;
return true;
}
// Walk a track's keys, remove redundant ones iteratively. Always preserves
// the first and last keyframe. Returns the number of keys removed.
static int simplifyTrackKeys(std::vector<SimpleKey>& keys,
const AnimationMerger::SimplifyTolerances& tol)
{
if (keys.size() < 3)
return 0;
// `kept` holds indices into the original `keys` array of keys we plan to keep.
// We extend it left-to-right and only commit a middle key when we're sure it
// can't be folded out by a later neighbor. Specifically, after appending
// index i, we look back at the previous "tentative" index j = kept[size-2]:
// if keys[j] is redundant given (kept[size-3], i) as neighbors, we drop j.
// This handles runs of collinear keys correctly.
std::vector<size_t> kept;
kept.reserve(keys.size());
kept.push_back(0);
for (size_t i = 1; i + 1 < keys.size(); ++i) {
kept.push_back(i);
// Try to fold out the previous tentative middle key while possible.
while (kept.size() >= 3) {
const size_t a = kept[kept.size() - 3];
const size_t b = kept[kept.size() - 2];
const size_t c = kept[kept.size() - 1];
if (keyIsRedundant(keys[a], keys[b], keys[c], tol)) {
kept.erase(kept.begin() + (kept.size() - 2));
} else {
break;
}
}
}
// Always keep the last keyframe; fold middle ones against it too.
kept.push_back(keys.size() - 1);
while (kept.size() >= 3) {
const size_t a = kept[kept.size() - 3];
const size_t b = kept[kept.size() - 2];
const size_t c = kept[kept.size() - 1];
if (keyIsRedundant(keys[a], keys[b], keys[c], tol)) {
kept.erase(kept.begin() + (kept.size() - 2));
} else {
break;
}
}
if (kept.size() == keys.size())
return 0;
std::vector<SimpleKey> reduced;
reduced.reserve(kept.size());
for (auto idx : kept)
reduced.push_back(keys[idx]);
int removed = static_cast<int>(keys.size() - reduced.size());
keys = std::move(reduced);
return removed;
}
} // namespace
int AnimationMerger::simplifyAnimation(Ogre::Skeleton* skel,
const std::string& animName,
const SimplifyTolerances& tol)
{
if (!skel || !skel->hasAnimation(animName))
return 0;
Ogre::Animation* srcAnim = skel->getAnimation(animName);
struct TrackData {
unsigned short handle = 0;
Ogre::Node* associatedNode = nullptr;
bool useShortestPath = true;
std::vector<SimpleKey> keys;
};
std::vector<TrackData> tracks;
int totalRemoved = 0;
for (const auto& [handle, srcTrack] : srcAnim->_getNodeTrackList())
{
TrackData td;
td.handle = handle;
td.associatedNode = srcTrack->getAssociatedNode();
td.useShortestPath = srcTrack->getUseShortestRotationPath();
unsigned short numKf = srcTrack->getNumKeyFrames();
td.keys.reserve(numKf);
for (unsigned short k = 0; k < numKf; ++k) {
const auto* kf = srcTrack->getNodeKeyFrame(k);
td.keys.push_back({kf->getTime(), kf->getTranslate(), kf->getRotation(), kf->getScale()});
}
totalRemoved += simplifyTrackKeys(td.keys, tol);
tracks.push_back(std::move(td));
}
if (totalRemoved == 0)
return 0;
const float animLength = srcAnim->getLength();
const auto interpMode = srcAnim->getInterpolationMode();
const auto rotInterpMode = srcAnim->getRotationInterpolationMode();
skel->removeAnimation(animName);
Ogre::Animation* newAnim = skel->createAnimation(animName, animLength);
newAnim->setInterpolationMode(interpMode);
newAnim->setRotationInterpolationMode(rotInterpMode);
for (const auto& td : tracks) {
if (td.keys.empty())
continue;
auto* newTrack = newAnim->createNodeTrack(td.handle);
if (td.associatedNode)
newTrack->setAssociatedNode(td.associatedNode);
newTrack->setUseShortestRotationPath(td.useShortestPath);
for (const auto& k : td.keys) {
auto* kf = newTrack->createNodeKeyFrame(k.time);
kf->setTranslate(k.translate);
kf->setRotation(k.rotation);
kf->setScale(k.scale);
}
}
return totalRemoved;
}
int AnimationMerger::bakeAnimationAtFps(Ogre::Skeleton* skel,
const std::string& animName,
int targetFps)
{
if (!skel || targetFps <= 0) return 0;
if (!skel->hasAnimation(animName)) return 0;
Ogre::Animation* anim = skel->getAnimation(animName);
if (!anim) return 0;
const float step = 1.0f / static_cast<float>(targetFps);
constexpr float kEps = 1e-4f;
int totalKeys = 0;
for (const auto& [handle, track] : anim->_getNodeTrackList()) {
const unsigned short numKf = track->getNumKeyFrames();
if (numKf < 2) {
totalKeys += numKf;
continue;
}
// Snapshot every existing keyframe so we can interpolate
// against the original curve after stripping the interior.
std::vector<SimpleKey> snap;
snap.reserve(numKf);
for (unsigned short k = 0; k < numKf; ++k) {
const auto* kf = track->getNodeKeyFrame(k);
snap.push_back({ kf->getTime(),
kf->getTranslate(),
kf->getRotation(),
kf->getScale() });
}
const float t0 = snap.front().time;
const float t1 = snap.back().time;
const float duration = t1 - t0;
if (duration <= 0.0f) {
totalKeys += numKf;
continue;
}
// Strip every keyframe (we'll re-create endpoints + interior).
for (int k = static_cast<int>(track->getNumKeyFrames()) - 1; k >= 0; --k) {
track->removeKeyFrame(static_cast<unsigned short>(k));
}
// Helper: lerp full TRS between bracketing snapshot keys.
auto sampleAt = [&snap](float t) -> SimpleKey {
const SimpleKey* lo = &snap.front();
const SimpleKey* hi = &snap.back();
for (size_t i = 0; i + 1 < snap.size(); ++i) {
if (t >= snap[i].time - 1e-4f
&& t <= snap[i+1].time + 1e-4f) {
lo = &snap[i];
hi = &snap[i+1];
break;
}
}
const float gap = hi->time - lo->time;
const float u = gap > 1e-6f
? std::clamp((t - lo->time) / gap, 0.0f, 1.0f) : 0.0f;
SimpleKey out;
out.time = t;
out.translate = lo->translate + (hi->translate - lo->translate) * u;
out.rotation = Ogre::Quaternion::Slerp(u, lo->rotation, hi->rotation, true);
out.scale = lo->scale + (hi->scale - lo->scale) * u;
return out;
};
// Insert uniform N-FPS grid: t0, t0+step, t0+2*step, ..., t1.
const int sampleCount = static_cast<int>(std::ceil(duration / step)) + 1;
for (int i = 0; i < sampleCount; ++i) {
float t = t0 + i * step;
if (t > t1 - kEps) t = t1;
const SimpleKey s = sampleAt(t);
auto* kf = track->createNodeKeyFrame(t);
kf->setTranslate(s.translate);
kf->setRotation(s.rotation);
kf->setScale(s.scale);
++totalKeys;
if (t >= t1 - kEps) break;
}
}
return totalKeys;
}
AnimationMerger::InbetweenResult AnimationMerger::inbetweenAnimation(
Ogre::Skeleton* skel, const std::string& animName,
float t0, float t1, int gapFrames, const QString& modelPath,
bool forceFallback)
{
InbetweenResult res;
if (!skel || !skel->hasAnimation(animName)) {
res.error = QStringLiteral("Animation not found.");
return res;
}
if (gapFrames <= 0) {
res.error = QStringLiteral("gapFrames must be >= 1.");
return res;
}
// Cap gapFrames: user-provided via CLI/MCP/GUI, and gapFrames(+1) drives both
// model/fallback allocation and per-track keyframe inserts. 1000 frames in a
// single gap is already absurd for any real clip.
constexpr int kMaxGapFrames = 1000;
if (gapFrames > kMaxGapFrames) {
res.error = QStringLiteral("gapFrames %1 exceeds the maximum of %2.")
.arg(gapFrames).arg(kMaxGapFrames);
return res;
}
// Reject non-finite times: NaN/inf would slip past `t1 <= t0` (all comparisons
// with NaN are false) and corrupt the sampling math downstream.
if (!std::isfinite(t0) || !std::isfinite(t1)) {
res.error = QStringLiteral("Start/end times must be finite.");
return res;
}
if (t1 <= t0) {
res.error = QStringLiteral("End time must be greater than start time.");
return res;
}
Ogre::Animation* anim = skel->getAnimation(animName);
if (!anim) { res.error = QStringLiteral("Animation not found."); return res; }
// One stable track order = the channel layout. Per bone we pack 10 DoF:
// [tx,ty,tz, qx,qy,qz,qw, sx,sy,sz]. Tracks that don't bracket the window
// are still packed (so the model sees a full pose) but we only WRITE new
// keys to tracks that have a real bracketing segment.
struct TrackCtx {
Ogre::NodeAnimationTrack* track = nullptr;
std::string boneName; // resolved from the skeleton by handle
bool bracketed = false; // has a key <= t0 and a key >= t1
SimpleKey startKey; // pose at/just-before t0
SimpleKey endKey; // pose at/just-after t1
};
auto evalAt = [](Ogre::NodeAnimationTrack* tr, float t) -> SimpleKey {
Ogre::TransformKeyFrame tmp(nullptr, t);
tr->getInterpolatedKeyFrame(Ogre::TimeIndex(t), &tmp);
return { t, tmp.getTranslate(), tmp.getRotation(), tmp.getScale() };
};
std::vector<TrackCtx> ctxs;
const auto& trackList = anim->_getNodeTrackList();
ctxs.reserve(trackList.size());
for (const auto& [handle, track] : trackList) {
TrackCtx c;
c.track = track;
// Tracks are keyed by bone HANDLE; resolve the bone name from the
// skeleton for canonical-role matching. (hasBone() takes a name, not a
// handle, so guard by handle range instead.)
if (handle < skel->getNumBones())
c.boneName = skel->getBone(handle)->getName();
const unsigned short numKf = track->getNumKeyFrames();
if (numKf >= 2) {
const float first = track->getNodeKeyFrame(0)->getTime();
const float last = track->getNodeKeyFrame(numKf - 1)->getTime();
// The window must be a genuine GAP: bracketed by keys at/outside
// [t0,t1] AND with no existing keyframe strictly inside (t0,t1).
// Inserting into an already-keyed span would stack new keys on top
// of authored ones — skip those tracks (they're not a gap to fill).
bool hasInteriorKey = false;
for (unsigned short k = 0; k < numKf; ++k) {
const float kt = track->getNodeKeyFrame(k)->getTime();
if (kt > t0 + 1e-4f && kt < t1 - 1e-4f) { hasInteriorKey = true; break; }
}
if (first <= t0 + 1e-4f && last >= t1 - 1e-4f && !hasInteriorKey) {
c.bracketed = true;
c.startKey = evalAt(track, t0);
c.endKey = evalAt(track, t1);
}
}
if (!c.bracketed) {
// Non-bracketed: hold the evaluated pose at t0 for both ends so the
// model still receives a coherent full-skeleton pose, but we won't
// write keys back to this track.
c.startKey = c.endKey = (numKf > 0) ? evalAt(track, t0)
: SimpleKey{ t0, Ogre::Vector3::ZERO,
Ogre::Quaternion::IDENTITY,
Ogre::Vector3::UNIT_SCALE };
}
ctxs.push_back(c);
}
if (ctxs.empty()) {
res.error = QStringLiteral("Animation has no node tracks.");
return res;
}
auto packPose = [](const SimpleKey& k, MotionInbetween::Pose& out, size_t at) {
out[at+0] = k.translate.x; out[at+1] = k.translate.y; out[at+2] = k.translate.z;
out[at+3] = k.rotation.x; out[at+4] = k.rotation.y;
out[at+5] = k.rotation.z; out[at+6] = k.rotation.w;
out[at+7] = k.scale.x; out[at+8] = k.scale.y; out[at+9] = k.scale.z;
};
auto writeKey = [&](Ogre::NodeAnimationTrack* track, float t,
const MotionInbetween::Pose& pose, size_t base) {
Ogre::TransformKeyFrame* kf = track->createNodeKeyFrame(t);
kf->setTranslate(Ogre::Vector3(pose[base+0], pose[base+1], pose[base+2]));
Ogre::Quaternion q(pose[base+6], pose[base+3], pose[base+4], pose[base+5]); // (w,x,y,z)
q.normalise();
kf->setRotation(q);
kf->setScale(Ogre::Vector3(pose[base+7], pose[base+8], pose[base+9]));
};
const float span = t1 - t0;
auto interiorT = [&](int f) {
return t0 + span * static_cast<float>(f + 1) / static_cast<float>(gapFrames + 1);
};
// --- Try the RMIB model via canonical-skeleton retargeting --------------
// The model is fixed to canonicalJointCount() joints. Map each track to a
// canonical index; if a model is present, enough roles resolve, and we're
// not forcing fallback, pack the canonical [2, C220] pose, run predict()
// (which fires the model since C matches), and scatter the prediction back
// onto each matched bracketed track. Unmatched / non-bracketed tracks are
// handled by the per-track spline pass below.
const int CJ = MotionInbetween::canonicalJointCount();
const size_t modelC = static_cast<size_t>(CJ) * 10;
std::vector<int> canonToTrack(CJ, -1); // canonical idx -> ctx idx
int rolesResolved = 0;
if (!forceFallback && !modelPath.isEmpty()) {
for (size_t bi = 0; bi < ctxs.size(); ++bi) {
const int ci = MotionInbetween::canonicalIndexForBone(
QString::fromStdString(ctxs[bi].boneName));
if (ci >= 0 && canonToTrack[ci] < 0) { canonToTrack[ci] = static_cast<int>(bi); ++rolesResolved; }
}
}
// Require a strong majority of roles before trusting the model (a partial
// skeleton would feed garbage to a fixed-layout net). ~75% of 22 = 17.
const bool useModel = !forceFallback && !modelPath.isEmpty()
&& rolesResolved >= (CJ * 3) / 4;
std::vector<bool> handledByModel(ctxs.size(), false);
if (useModel) {
MotionInbetween::Pose startPose(modelC, 0.0f), endPose(modelC, 0.0f);
std::vector<MotionInbetween::Channel> mlayout(modelC, MotionInbetween::Channel::Scalar);
for (int cj = 0; cj < CJ; ++cj) {
const size_t at = static_cast<size_t>(cj) * 10;
mlayout[at+3] = MotionInbetween::Channel::QuatStart;
mlayout[at+4] = MotionInbetween::Channel::QuatCont;
mlayout[at+5] = MotionInbetween::Channel::QuatCont;
mlayout[at+6] = MotionInbetween::Channel::QuatCont;
const int bi = canonToTrack[cj];
if (bi >= 0) { packPose(ctxs[bi].startKey, startPose, at);
packPose(ctxs[bi].endKey, endPose, at); }
else { startPose[at+6] = endPose[at+6] = 1.0f; // identity quat
startPose[at+7] = startPose[at+8] = startPose[at+9] = 1.0f;
endPose[at+7] = endPose[at+8] = endPose[at+9] = 1.0f; }
}
MotionInbetween::Options opts; opts.gapFrames = gapFrames;
const MotionInbetween::Result mr =
MotionInbetween::predict(startPose, endPose, mlayout, modelPath, opts);
if (mr.ok && mr.usedModel) {
res.usedModel = true;
for (int cj = 0; cj < CJ; ++cj) {
const int bi = canonToTrack[cj];
if (bi < 0 || !ctxs[bi].bracketed) continue;
for (int f = 0; f < gapFrames; ++f) {
writeKey(ctxs[bi].track, interiorT(f), mr.frames[f],
static_cast<size_t>(cj) * 10);
++res.keyframesInserted;
}
++res.tracksAffected;
handledByModel[bi] = true;
}
} else {
res.fallbackReason = mr.fallbackReason; // model declined → spline below
}
}
// --- Per-track spline pass (every bracketed track the model didn't do) ---
for (size_t bi = 0; bi < ctxs.size(); ++bi) {
if (!ctxs[bi].bracketed || handledByModel[bi]) continue;
// Single-bone spline: layout is the 10-DoF for one bone.
MotionInbetween::Pose s(10), e(10);
packPose(ctxs[bi].startKey, s, 0);
packPose(ctxs[bi].endKey, e, 0);
std::vector<MotionInbetween::Channel> oneLayout = {
MotionInbetween::Channel::Scalar, MotionInbetween::Channel::Scalar,
MotionInbetween::Channel::Scalar, MotionInbetween::Channel::QuatStart,
MotionInbetween::Channel::QuatCont, MotionInbetween::Channel::QuatCont,
MotionInbetween::Channel::QuatCont, MotionInbetween::Channel::Scalar,
MotionInbetween::Channel::Scalar, MotionInbetween::Channel::Scalar };
MotionInbetween::Options o; o.gapFrames = gapFrames; o.forceFallback = true;
const auto sr = MotionInbetween::interpolateSpline(s, e, oneLayout, o);
if (!sr.ok) continue;
for (int f = 0; f < gapFrames; ++f) {
writeKey(ctxs[bi].track, interiorT(f), sr.frames[f], 0);
++res.keyframesInserted;
}
++res.tracksAffected;
if (!res.usedModel && res.fallbackReason.isEmpty())
res.fallbackReason = QStringLiteral("Used the spline fallback.");
}
if (res.tracksAffected == 0) {
res.error = QStringLiteral(
"No track had a keyframe pair bracketing [%1, %2] — nothing to fill.")
.arg(t0).arg(t1);
return res;
}
res.ok = true;
return res;
}
std::vector<AnimationMerger::CanonicalClip>
AnimationMerger::extractCanonicalClips(Ogre::Entity* entity, int fps,
const QString& onlyAnimation)
{
std::vector<CanonicalClip> out;
if (!entity || !entity->hasSkeleton() || fps <= 0)
return out;
Ogre::SkeletonInstance* skel = entity->getSkeleton();
if (!skel)
return out;
const int J = MotionInbetween::canonicalJointCount();
// Bone → canonical role, first match wins per role (the same matcher the
// retarget uses, so round-trips are consistent by construction).
std::vector<Ogre::Bone*> roleBone(static_cast<size_t>(J), nullptr);
int resolved = 0;
for (auto* bone : skel->getBones()) {
const int role = MotionInbetween::canonicalIndexForBone(
QString::fromStdString(bone->getName()));
if (role >= 0 && role < J && !roleBone[static_cast<size_t>(role)]) {
roleBone[static_cast<size_t>(role)] = bone;
++resolved;
}
}
if (resolved == 0)
return out;
// ── source-frame → canonical-frame conjugation ─────────────────────
// Scraped rigs live in arbitrary file frames (Blender FBX armatures are
// commonly Z-up), while the motion library's world convention is Y-up,
// +Z-facing, +X-left. Guessing per format is fragile — derive the
// source frame from the rig's own geometry instead: up = hip→head,
// left = right-hip→left-hip (shoulders as fallback), forward = left×up.
// Every sampled world quat is then conjugated: q' = C · q · C⁻¹.
//
// CRITICAL: the frame, reference orientations AND bone directions are
// all measured at an ANIMATED calm frame of the clip — never at the
// bind/reset pose. On many scraped rigs (Quaternius, Sketchfab glTF)
// the animation worlds differ from the reset pose by a constant global