This page looks best with JavaScript enabled

Creating New Anim Nodes Pt. 2

 ·  ☕ 14 min read  ·  ✍️ Taylor

Intro

In the last part we created the AnimNode_GradualScaleJoints anim node and initialized it in such a way that we could add it to an AnimBP graph. In this part I’ll go over the individual parts of the AnimNode class, order of operations, and how we want to go about the joint scaling.

General thoughts

As with anything involving runtime animation, remember to always be as lean as possible. Animation runs every single frame. You don’t want to be doing complex validations or transformations if you can help it. Unreal does help out with this by multithreading this stuff, but that isn’t an excuse to write bad code and abuse the system.

Design Pt. 2

So now that we have the node running in our AnimBP, let’s revisit what we want this node to do.

We want to drive the scale of bones based on the values along a user-generate curve. To do this, we need to know a few things:

  1. The depth of any particular joint relative to the ChainStart joint. Is it a direct child? Is it a distant relative? We need to know this to grab the float value from the curve at the correct time along the curve.
  2. Do we want to mask off certain axes of the joints that the scale won’t apply to? For me this was the X axis, down the bones. It looks really bad.
    1. Remember, we are operating in ComponentSpace. Scaling a parent bone WILL NOT translate a child bone in world space. Sure, we could figure out all the math needed to do that, but it would probably be heavy and it’s outside the scope of this guide.
  3. Do we want the scale to go from the ChainStart all the way to the very end of the chain? Or do we want it to have a set depth it will operate over?
  4. If we do have a set depth we want to scale over, do we want the scale of the deepest bone we’re scaling to apply to all its children? So for instance, Hand_L is scaled to 1.5. Do we want all the fingers to be locked in at 1.5 as well?

In order to accomplish this all we’re going to add some stuff to the AnimNode_GradualScaleJoints.h file.

 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
USTRUCT()
struct FBoneReferenceDepth
{
   GENERATED_BODY()

   FBoneReference BoneReference;

   int32 BoneDepth;

   FBoneReferenceDepth()
   {
      BoneReference = FBoneReference();
      BoneDepth = -1;
   }

   FBoneReferenceDepth(const FBoneReference& boneReference, const int32 boneDepth)
   {
      BoneReference = boneReference;
      BoneDepth = boneDepth;
   }
};

USTRUCT()
struct FAxisMask
{
   GENERATED_BODY()

   UPROPERTY(EditAnywhere)
   bool MaskX;

   UPROPERTY(EditAnywhere)
   bool MaskY;

   UPROPERTY(EditAnywhere)
   bool MaskZ;

   FAxisMask()
   {
      MaskX = false;
      MaskY = false;
      MaskZ = false;
   }
};

The FBoneReferenceDepth struct will be a lightway way for us to gather a BoneReference/BoneDepth pair and pass them around. We could possibly use a TMap<FBoneReference, int32> to store this stuff, but I would be hesitate to do so because of the threading involved in evaluation.

The FAxisMask struct is pretty self-explanatory. It’s a simple way for us to display each axis bool to the user. If this node were part of a larger ecosystem I would likely move this struct out into its own namespace for reuse.

Once these are made, add a few more params to the class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
   /* Mask off the scale from each axis individually */
   UPROPERTY(EditAnywhere, Category = "Gradual Scale Joints")
   FAxisMask AxisMasks;
   
   /* The maximum hierarchy depth to which we will do the scale. If set to -1, we will scale to the end of the chain */
   UPROPERTY(EditAnywhere, Category = "Gradual Scale Joints")
   int16 MaximumDepth;

   /* If true, we will set the scale to all joints DEEPER THAN MaximumDepth to the scale of the joint AT MaximumDepth */
   UPROPERTY(EditAnywhere, Category = "Gradual Scale Joints")
   bool ContinuousScale;

   TArray<FBoneReferenceDepth> BoneRefDepthBuckets;
   int16 foundMaxDepth;
   int16 effectiveMaxDepth;
  • foundMaxDepth is the greatest hierarchy depth from our ChainStart to the very end of the chain. We need this to scale our FloatCurve values later on
  • effectiveMaxDepth will be used to determine where we want the scale to end: the very end of the chain, or some arbitrary, user-defined depth?

This is it for the .h file. Now, I’ll jump into the order of operations AnimNodes go through to arrive at a final, evaluated pose.

Order of Operations

Initialize_AnyThread

This is used to set up any objects we need for the rest of the calculation. For instance, if you need to initialize some sockets, it will be done here. We don’t need to do that so we won’t be using this.

This will run when the graph is compiled, and when the SkeletalMeshComponent’s LOD changes.

CacheBones_AnyThread

This is used to cache incoming poses. In the case of the SkeletalControl anim node, that is the ComponentPose pin that plugs into the node. This will run ONCE when a mesh LOD is initialized.

We can use this, but we won’t, because…

InitializeBoneReferences

…it calls InitializeBoneReferences, so we will use this.

Just like the CacheBones_AnyThread method, InitializeBoneReferences only runs once when a mesh LOD is initialized. Therefore, we want to do as much caching and validation here as possible to make the per-frame operations later cheap and fast.

This is where we want to take our ChainStart and grab all of its child bones, then order those bones into buckets based on depth from ChainStart.

 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
void FAnimNode_GradualScaleJoints::InitializeBoneReferences(const FBoneContainer& requiredBones)
{
   DECLARE_SCOPE_HIERARCHICAL_COUNTER_ANIMNODE(InitializeBoneReferences)

   ChainStart.Initialize(requiredBones);

   if (ChainStart.IsValidToEvaluate())
   {
      const FReferenceSkeleton ReferenceSkeleton = requiredBones.GetReferenceSkeleton();
      for (int16 boneInd = 0 ; boneInd < ReferenceSkeleton.GetNum() ; boneInd++)
      {
         FBoneReference childBoneRef = FBoneReference(ReferenceSkeleton.GetBoneName(boneInd));
         if(childBoneRef.Initialize(requiredBones))
         {
            const int32 boneDepth = ReferenceSkeleton.GetDepthBetweenBones(childBoneRef.BoneIndex, ChainStart.BoneIndex);
            if (boneDepth >= 0)
            {
               foundMaxDepth = boneDepth > foundMaxDepth ? boneDepth : foundMaxDepth;
               BoneRefDepthBuckets.Add(FBoneReferenceDepth(childBoneRef, boneDepth));
            }
         }
      }

      effectiveMaxDepth = MaximumDepth == -1 ? foundMaxDepth : FMath::Min(MaximumDepth, foundMaxDepth);
   }
}

You must ALWAYS initialize BoneReferences with a BoneContainer. Once they are, we can ask IsValidToEvaluate and continue on with our initialize step.

FReferenceSkeleton has a ton of useful functionality. Here, we will be using GetDepthBetweenBones. It will return an int with the depth between two joints, or -1 if they aren’t in a parent/child relationship.

Once we gather all of our child joints into our depth buckets, we need to determine what our effectiveMaxDepth should be. Note: if you want this to change during runtime, move it to the EvaluateSkeletalControl_AnyThread method. I didn’t need that for my purposes so it’s here.

IsValidToEvaluate

This is a preliminary go/no-go for the evaluation step. If this returns true, we evaluate; false, we exit.

This runs every single frame, so don’t do any calculation or anything here. This should be a fairly simple check that everything on the node is correct, valid, and can be evaluated.

1
2
3
4
5
6
7
8
bool FAnimNode_GradualScaleJoints::IsValidToEvaluate(const USkeleton* skeleton, const FBoneContainer& requiredBones)
{
   if (ChainStart.IsValidToEvaluate() && ScaleCurve != nullptr && ScaleCurve->FloatCurve.GetNumKeys() > 0)
   {
      return true;
   }
   return false;
}

EvaluateSkeletalControl_AnyThread

Okay, here’s where we actually do The Stuff. We’ve gathered the necessary data, ensured that we can evaluate, and now we’re going to actually do it:

 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
void FAnimNode_GradualScaleJoints::EvaluateSkeletalControl_AnyThread(FComponentSpacePoseContext& output,
                                                                     TArray<FBoneTransform>& outBoneTransforms)
{
   DECLARE_SCOPE_HIERARCHICAL_COUNTER_ANIMNODE(EvaluateSkeletalControl_AnyThread)
   SCOPE_CYCLE_COUNTER(STAT_GradualScaleJoints_Eval);

   check(outBoneTransforms.Num() == 0);

   const FBoneContainer& boneContainer = output.Pose.GetPose().GetBoneContainer();
   const float finalKeyTime = ScaleCurve->FloatCurve.GetLastKey().Time;
   
   for (const FBoneReferenceDepth boneReferenceDepth : BoneRefDepthBuckets)
   {
      const FCompactPoseBoneIndex boneCPB = boneReferenceDepth.BoneReference.GetCompactPoseIndex(boneContainer);
      if (boneCPB == INDEX_NONE)
      {
         continue;
      }

      float curveScaleValue = 1.f;
      if (boneReferenceDepth.BoneDepth <= effectiveMaxDepth)
      {
         curveScaleValue = ScaleCurve->GetFloatValue((finalKeyTime / effectiveMaxDepth) * boneReferenceDepth.BoneDepth);
      }
      else if (ContinuousScale)
      {
         curveScaleValue = ScaleCurve->GetFloatValue((finalKeyTime / effectiveMaxDepth) * effectiveMaxDepth);
      }
      
      FTransform boneTransform = output.Pose.GetComponentSpaceTransform(boneCPB);
      const FVector boneScale = boneTransform.GetScale3D();
      const FVector scaledBoneScale = boneScale * curveScaleValue;
      boneTransform.SetScale3D(FVector( AxisMasks.MaskX ? boneScale.X : scaledBoneScale.X,
                                       AxisMasks.MaskY ? boneScale.Y : scaledBoneScale.Y,
                                       AxisMasks.MaskZ ? boneScale.Z : scaledBoneScale.Z));

      outBoneTransforms.Add(FBoneTransform(boneCPB, boneTransform));

      outBoneTransforms.Sort(FCompareBoneTransformIndex());
   }
}

The meat and potatoes. Let’s go over it in more detail:

1
2
3
4
5
      const FCompactPoseBoneIndex boneCPB = boneReferenceDepth.BoneReference.GetCompactPoseIndex(boneContainer);
      if (boneCPB == INDEX_NONE)
      {
         continue;
      }

This is the final go/no-go. If a BoneReference has an INDEX_NONE CompactPoseIndex, that means it is not in the current reference skeleton, ie. has been LOD’d out. We CANNOT operate on a bone that has been LOD’d. It’ll crash. This check ensures that everything we touch from here on out is valid to be worked on.

1
2
3
4
5
6
7
8
9
      float curveScaleValue = 1.f;
      if (boneReferenceDepth.BoneDepth <= effectiveMaxDepth)
      {
         curveScaleValue = ScaleCurve->GetFloatValue((finalKeyTime / effectiveMaxDepth) * boneReferenceDepth.BoneDepth);
      }
      else if (ContinuousScale)
      {
         curveScaleValue = ScaleCurve->GetFloatValue((finalKeyTime / effectiveMaxDepth) * effectiveMaxDepth);
      }

This is pretty self-explanatory, right? If the depth of the bone we’re working on is less than or equal to the depth we want to scale to, we find where that depth would line up on our float curve, and set the scale value to that value.

If it doesn’t, which means we are beyond the desired depth, our scale should just be 1.f…unless we have ContinuousScale set to true, in which case we want to set our scale to the final value on the float curve.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
      FTransform boneTransform = output.Pose.GetComponentSpaceTransform(boneCPB);
      const FVector boneScale = boneTransform.GetScale3D();
      const FVector scaledBoneScale = boneScale * curveScaleValue;
      boneTransform.SetScale3D(FVector( AxisMasks.MaskX ? boneScale.X : scaledBoneScale.X,
                                       AxisMasks.MaskY ? boneScale.Y : scaledBoneScale.Y,
                                       AxisMasks.MaskZ ? boneScale.Z : scaledBoneScale.Z));

      outBoneTransforms.Add(FBoneTransform(boneCPB, boneTransform));

      outBoneTransforms.Sort(FCompareBoneTransformIndex());

Finally, we’ll grab the ComponentSpaceTransform of the bone we’re working on, set it’s scale according to the masks we have set, and then add our transform to the outBoneTransforms array.

Now, one final note: you CANNOT evaluate bones out of order. You can’t do a child before a parent. Therefore, we sort the array after every addition using the FCompareBoneTransformIndex predicate. This ensures our array is always in parent->child order.

Final Steps

Now you just need to create a FloatCurve asset in the browser, set all your parameters on the node, and start messing around.



I hope this helps a bit. If you have any questions, comments, or suggestions, leave a comment or ping me on Twitter!

Full Code

AnimNode_GradualScaleJoints

 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
#pragma once

#include "AnimGraphRuntime/Public/BoneControllers/AnimNode_SkeletalControlBase.h"
#include "BoneContainer.h"
#include "BonePose.h"
#include "CoreMinimal.h"
#include "AnimNode_GradualScaleJoints.generated.h"

USTRUCT()
struct FBoneReferenceDepth
{
   GENERATED_BODY()

   FBoneReference BoneReference;

   int32 BoneDepth;

   FBoneReferenceDepth()
   {
      BoneReference = FBoneReference();
      BoneDepth = -1;
   }

   FBoneReferenceDepth(const FBoneReference& boneReference, const int32 boneDepth)
   {
      BoneReference = boneReference;
      BoneDepth = boneDepth;
   }
};

USTRUCT()
struct FAxisMask
{
   GENERATED_BODY()

   UPROPERTY(EditAnywhere)
   bool MaskX;

   UPROPERTY(EditAnywhere)
   bool MaskY;

   UPROPERTY(EditAnywhere)
   bool MaskZ;

   FAxisMask()
   {
      MaskX = false;
      MaskY = false;
      MaskZ = false;
   }
};

USTRUCT(BlueprintInternalUseOnly)
struct MYMODULE_API FAnimNode_GradualScaleJoints : public FAnimNode_SkeletalControlBase
{
   GENERATED_BODY();
public:

   FAnimNode_GradualScaleJoints();

   // FAnimNode_Base interface
   virtual void GatherDebugData(FNodeDebugData& debugData) override;
   virtual bool NeedsOnInitializeAnimInstance() const override { return true; }
   // End of FAnimNode_Base interface

   // FAnimNode_SkeletalControlBase interface
   virtual void EvaluateSkeletalControl_AnyThread(FComponentSpacePoseContext& output, TArray<FBoneTransform>& outBoneTransforms) override;
   virtual bool IsValidToEvaluate(const USkeleton* skeleton, const FBoneContainer& requiredBones) override;
   // End of FAnimNode_SkeletalControlBase interface

private:
   // FAnimNode_SkeletalControlBase interface
   virtual void InitializeBoneReferences(const FBoneContainer& requiredBones) override;
   // End of FAnimNode_SkeletalControlBase interface

   /* The bone to start the scale at. Everything below this will be scaled by depth. */
   UPROPERTY(EditAnywhere, Category = "Gradual Scale Joints")
   FBoneReference ChainStart;
   
   /* The float curve that will drive the scale of the joints */
   UPROPERTY(EditAnywhere, Category = "Gradual Scale Joints")
   UCurveFloat* ScaleCurve;
   
   /* Mask off the scale from each axis individually */
   UPROPERTY(EditAnywhere, Category = "Gradual Scale Joints")
   FAxisMask AxisMasks;
   
   /* The maximum hierarchy depth to which we will do the scale. If set to -1, we will scale to the end of the chain */
   UPROPERTY(EditAnywhere, Category = "Gradual Scale Joints")
   int16 MaximumDepth;

   /* If true, we will set the scale to all joints DEEPER THAN MaximumDepth to the scale of the joint AT MaximumDepth */
   UPROPERTY(EditAnywhere, Category = "Gradual Scale Joints")
   bool ContinuousScale;

   TArray<FBoneReferenceDepth> BoneRefDepthBuckets;
   int16 foundMaxDepth;
   int16 effectiveMaxDepth;
};
  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

#include "Animation/AnimNode_GradualScaleJoints.h"
#include "Animation/AnimInstanceProxy.h"
#include "AnimationRuntime.h"
#include "Curves/CurveFloat.h"

DECLARE_CYCLE_STAT(TEXT("Gradual Scale Joints Eval"), STAT_GradualScaleJoints_Eval, STATGROUP_Anim);

/////////////////////////////////////////////////////
// FAnimNode_GradualScaleJoint

FAnimNode_GradualScaleJoints::FAnimNode_GradualScaleJoints()
   : ScaleCurve(nullptr)
   , MaximumDepth(-1)
   , ContinuousScale(false)
   , foundMaxDepth(-1)
   , effectiveMaxDepth(-1)
{
}

void FAnimNode_GradualScaleJoints::GatherDebugData(FNodeDebugData& debugData)
{
   DECLARE_SCOPE_HIERARCHICAL_COUNTER_ANIMNODE(GatherDebugData)

   FString DebugLine = debugData.GetNodeName(this);

   DebugLine += "(";
   AddDebugNodeData(DebugLine);
   DebugLine += FString::Printf(TEXT(")"));
   debugData.AddDebugItem(DebugLine);

   ComponentPose.GatherDebugData(debugData);
}

void FAnimNode_GradualScaleJoints::EvaluateSkeletalControl_AnyThread(FComponentSpacePoseContext& output,
                                                                     TArray<FBoneTransform>& outBoneTransforms)
{
   DECLARE_SCOPE_HIERARCHICAL_COUNTER_ANIMNODE(EvaluateSkeletalControl_AnyThread)
   SCOPE_CYCLE_COUNTER(STAT_GradualScaleJoints_Eval);

   check(outBoneTransforms.Num() == 0);

   const FBoneContainer& boneContainer = output.Pose.GetPose().GetBoneContainer();
   const float finalKeyTime = ScaleCurve->FloatCurve.GetLastKey().Time;
   
   for (const FBoneReferenceDepth boneReferenceDepth : BoneRefDepthBuckets)
   {
      const FCompactPoseBoneIndex boneCPB = boneReferenceDepth.BoneReference.GetCompactPoseIndex(boneContainer);
      if (boneCPB == INDEX_NONE)
      {
         continue;
      }

      float curveScaleValue = 1.f;
      if (boneReferenceDepth.BoneDepth <= effectiveMaxDepth)
      {
         curveScaleValue = ScaleCurve->GetFloatValue((finalKeyTime / effectiveMaxDepth) * boneReferenceDepth.BoneDepth);
      }
      else if (ContinuousScale)
      {
         curveScaleValue = ScaleCurve->GetFloatValue((finalKeyTime / effectiveMaxDepth) * effectiveMaxDepth);
      }
      
      FTransform boneTransform = output.Pose.GetComponentSpaceTransform(boneCPB);
      const FVector boneScale = boneTransform.GetScale3D();
      const FVector scaledBoneScale = boneScale * curveScaleValue;
      boneTransform.SetScale3D(FVector( AxisMasks.MaskX ? boneScale.X : scaledBoneScale.X,
                                       AxisMasks.MaskY ? boneScale.Y : scaledBoneScale.Y,
                                       AxisMasks.MaskZ ? boneScale.Z : scaledBoneScale.Z));

      outBoneTransforms.Add(FBoneTransform(boneCPB, boneTransform));

      outBoneTransforms.Sort(FCompareBoneTransformIndex());
   }
}

bool FAnimNode_GradualScaleJoints::IsValidToEvaluate(const USkeleton* skeleton, const FBoneContainer& requiredBones)
{
   if (ChainStart.IsValidToEvaluate() && ScaleCurve != nullptr && ScaleCurve->FloatCurve.GetNumKeys() > 0)
   {
      return true;
   }
   return false;
}

void FAnimNode_GradualScaleJoints::InitializeBoneReferences(const FBoneContainer& requiredBones)
{
   DECLARE_SCOPE_HIERARCHICAL_COUNTER_ANIMNODE(InitializeBoneReferences)

   ChainStart.Initialize(requiredBones);

   if (ChainStart.IsValidToEvaluate())
   {
      const FReferenceSkeleton ReferenceSkeleton = requiredBones.GetReferenceSkeleton();
      for (int16 boneInd = 0 ; boneInd < ReferenceSkeleton.GetNum() ; boneInd++)
      {
         FBoneReference childBoneRef = FBoneReference(ReferenceSkeleton.GetBoneName(boneInd));
         if(childBoneRef.Initialize(requiredBones))
         {
            const int32 boneDepth = ReferenceSkeleton.GetDepthBetweenBones(childBoneRef.BoneIndex, ChainStart.BoneIndex);
            if (boneDepth >= 0)
            {
               foundMaxDepth = boneDepth > foundMaxDepth ? boneDepth : foundMaxDepth;
               BoneRefDepthBuckets.Add(FBoneReferenceDepth(childBoneRef, boneDepth));
            }
         }
      }

      effectiveMaxDepth = MaximumDepth == -1 ? foundMaxDepth : FMath::Min(MaximumDepth, foundMaxDepth);
   }
}

AnimGraphNode_GradualScaleJoints

 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
#pragma once

#include "AnimGraph/Classes/AnimGraphNode_SkeletalControlBase.h"
#include "Animation/AnimNode_GradualScaleJoints.h"
#include "CoreMinimal.h"
#include "UObject/ObjectMacros.h"
#include "AnimGraphNode_GradualScaleJoints.generated.h"

UCLASS(MinimalAPI)
class UAnimGraphNode_GradualScaleJoints : public UAnimGraphNode_SkeletalControlBase
{
   GENERATED_BODY()

public:

   // UEdGraphNode interface
   virtual FText GetNodeTitle(ENodeTitleType::Type titleType) const override;
   virtual FText GetTooltipText() const override;
   // End of UEdGraphNode interface

protected:
   
   // UAnimGraphNode_SkeletalControlBase interface
   virtual FText GetControllerDescription() const override;
   virtual const FAnimNode_SkeletalControlBase* GetNode() const override { return &Node; }
   virtual void Draw(FPrimitiveDrawInterface* PDI, USkeletalMeshComponent * previewSkelMeshComp) const override;
   // End of UAnimGraphNode_SkeletalControlBase interface

private:
   
   UPROPERTY(EditAnywhere, Category = Settings)
   FAnimNode_GradualScaleJoints Node;
   
};
 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
#include "Animation/AnimGraphNode_GradualScaleJoints.h"
#include "Components/SkeletalMeshComponent.h"

/////////////////////////////////////////////////////
// UAnimGraphNode_GradualScaleJoints

#define LOCTEXT_NAMESPACE "A3Nodes"

FText UAnimGraphNode_GradualScaleJoints::GetControllerDescription() const
{
   return LOCTEXT("GradualScaleJoints", "Gradual Scale Joints");
}

FText UAnimGraphNode_GradualScaleJoints::GetNodeTitle(ENodeTitleType::Type titleType) const
{
   return GetControllerDescription();
}

FText UAnimGraphNode_GradualScaleJoints::GetTooltipText() const
{
   return LOCTEXT("AnimGraphNode_GradualScaleJoints_Tooltip", "Scales a chain of joints based on a curve and a delta values");
}

void UAnimGraphNode_GradualScaleJoints::Draw(FPrimitiveDrawInterface* PDI, USkeletalMeshComponent* previewSkelMeshComp) const
{
}

#undef LOCTEXT_NAMESPACE
Share on