Add **Spring Bone Physics** node to Anim Graph

This commit is contained in:
2026-04-14 16:02:17 +02:00
parent 5b7af2e10c
commit 9bb7733b33
5 changed files with 247 additions and 8 deletions
@@ -1092,6 +1092,40 @@ namespace FlaxEditor.Surface.Archetypes
Utils.GetEmptyArray<byte>(),
},
},
new NodeArchetype
{
TypeID = 35,
Title = "Spring Bone Physics",
Description = "Simulates a soft physics-based trailing motion―like antennae, tails, or cloth strips―for a connected chain of skeletal bones.",
Flags = NodeFlags.AnimGraph,
Size = new Float2(250, 160),
DefaultValues = new object[]
{
string.Empty, // End Node
1, // Nodes
1.0f, // Weight
0.0f, // Stiffness
0.1f, // Drag
0.0f, // Stretch Limit
1.0f, // Gravity Scale
Vector3.Zero, // Force
},
Elements = new[]
{
NodeElementArchetype.Factory.Output(0, string.Empty, typeof(void), 0),
NodeElementArchetype.Factory.Input(0, string.Empty, true, typeof(void), 1),
NodeElementArchetype.Factory.Input(1, "Weight", true, typeof(float), 2, 2),
NodeElementArchetype.Factory.Input(2, "Stiffness", true, typeof(float), 3, 3),
NodeElementArchetype.Factory.Input(3, "Drag", true, typeof(float), 4, 4),
NodeElementArchetype.Factory.Input(4, "Stretch Limit", true, typeof(float), 5, 5),
NodeElementArchetype.Factory.Input(5, "Gravity Scale", true, typeof(float), 6, 6),
NodeElementArchetype.Factory.Input(6, "Force", true, typeof(Vector3), 7, 7),
NodeElementArchetype.Factory.SkeletonNodeNameSelect(60, Surface.Constants.LayoutOffsetY * 7, 140, 0),
NodeElementArchetype.Factory.Integer(60, Surface.Constants.LayoutOffsetY * 8, 1, -1, 1, 32),
NodeElementArchetype.Factory.Text(0, Surface.Constants.LayoutOffsetY * 7, "End Node:", tooltip:"The last (tip) node of the spring bones chain to simulate."),
NodeElementArchetype.Factory.Text(0, Surface.Constants.LayoutOffsetY * 8, "Nodes:", tooltip:"Amount of nodes in a chain to simulate, starting from the End Node going up in the hierarchy. Excluding root node the chain is attached to."),
}
},
};
}
}
@@ -117,6 +117,12 @@ void InstanceDataBucketInit(AnimGraphInstanceData::Bucket& bucket)
bucket.InstanceData.Init = true;
}
void SpringBonePhysicsBucketInit(AnimGraphInstanceData::Bucket& bucket)
{
bucket.SpringBonePhysics.LastUpdateFrame = 0;
bucket.SpringBonePhysics.StateDataStart = -1;
}
bool SortMultiBlend1D(const ANIM_GRAPH_MULTI_BLEND_INDEX& a, const ANIM_GRAPH_MULTI_BLEND_INDEX& b, AnimGraphNode* n)
{
// Sort items by X location from the lowest to the highest
@@ -313,18 +319,24 @@ bool AnimGraphBase::onNodeLoaded(Node* n)
// Transform Node (local/model space)
// Get Node Transform (local/model space)
// IK Aim, Two Bone IK
// Spring Bone Physics
case 25:
case 26:
case 28:
case 29:
case 30:
case 31:
case 35:
{
auto& data = n->Data.TransformNode;
if (_graph->BaseModel && !_graph->BaseModel->WaitForLoaded())
data.NodeIndex = _graph->BaseModel->FindNode((StringView)n->Values[0]);
else
data.NodeIndex = -1;
if (n->TypeID == 35)
{
ADD_BUCKET(SpringBonePhysicsBucketInit);
}
break;
}
// Copy Node
+32 -6
View File
@@ -5,6 +5,7 @@
#include "Engine/Animations/AnimEvent.h"
#include "Engine/Content/Assets/SkinnedModel.h"
#include "Engine/Graphics/Models/SkeletonData.h"
#include "Engine/Level/Actor.h"
#include "Engine/Scripting/Scripting.h"
#include "Engine/Threading/Threading.h"
@@ -24,17 +25,33 @@ Transform AnimGraphImpulse::GetNodeModelTransformation(SkeletonData& skeleton, i
return parentTransform.LocalToWorld(Nodes[nodeIndex]);
}
void AnimGraphImpulse::SetNodeModelTransformation(SkeletonData& skeleton, int32 nodeIndex, const Transform& value)
void AnimGraphImpulse::SetNodeModelTransformation(SkeletonData& skeleton, int32 nodeIndex, const Transform& value, float weight)
{
Transform transform = value;
const int32 parentIndex = skeleton.Nodes[nodeIndex].ParentIndex;
if (parentIndex == -1)
if (parentIndex != -1)
{
Nodes[nodeIndex] = value;
return;
const Transform parentTransform = GetNodeModelTransformation(skeleton, parentIndex);
parentTransform.WorldToLocal(transform, transform);
}
const Transform parentTransform = GetNodeModelTransformation(skeleton, parentIndex);
parentTransform.WorldToLocal(value, Nodes[nodeIndex]);
if (weight >= 1.0f)
Nodes[nodeIndex] = transform;
else
Transform::Lerp(Nodes[nodeIndex], transform, weight, Nodes[nodeIndex]);
}
Transform AnimGraphImpulse::GetNodeWorldTransformation(const AnimGraphContext& context, SkeletonData& skeleton, int32 nodeIndex) const
{
Transform transform = GetNodeModelTransformation(skeleton, nodeIndex);
return context.Data->GetObjectTransform().LocalToWorld(transform);
}
void AnimGraphImpulse::SetNodeWorldTransformation(const AnimGraphContext& context, SkeletonData& skeleton, int32 nodeIndex, const Transform& value)
{
Transform transform;
context.Data->GetObjectTransform().WorldToLocal(value, transform);
SetNodeModelTransformation(skeleton, nodeIndex, transform);
}
void AnimGraphInstanceData::Clear()
@@ -56,6 +73,7 @@ void AnimGraphInstanceData::ClearState()
RootTransform = Transform::Identity;
RootMotion = Transform::Identity;
State.Resize(0);
DynamicState.Resize(0);
NodesPose.Resize(0);
TraceEvents.Clear();
}
@@ -91,6 +109,13 @@ void AnimGraphInstanceData::InvokeAnimEvents()
}
}
Transform AnimGraphInstanceData::GetObjectTransform() const
{
if (auto* actor = ScriptingObject::Cast<Actor>(Object))
return actor->GetTransform();
return Transform::Identity;
}
AnimGraphInstanceData::OutgoingEvent AnimGraphInstanceData::ActiveEvent::End(AnimatedModel* actor) const
{
OutgoingEvent out;
@@ -285,6 +310,7 @@ void AnimGraphExecutor::Update(AnimGraphInstanceData& data, float dt)
{
// Prepare memory for buckets state information
data.State.Resize(_graph.BucketsCountTotal, false);
data.DynamicState.Clear();
// Initialize buckets
ResetBuckets(context, &_graph);
+29 -2
View File
@@ -26,6 +26,7 @@ class AnimGraphExecutor;
class AnimatedModel;
class AnimEvent;
class AnimContinuousEvent;
struct AnimGraphContext;
class SkinnedModel;
class SkeletonData;
@@ -60,14 +61,16 @@ struct FLAXENGINE_API AnimGraphImpulse
{
return Nodes[nodeIndex];
}
FORCE_INLINE void SetNodeLocalTransformation(SkeletonData& skeleton, int32 nodeIndex, const Transform& value)
{
Nodes[nodeIndex] = value;
}
Transform GetNodeModelTransformation(SkeletonData& skeleton, int32 nodeIndex) const;
void SetNodeModelTransformation(SkeletonData& skeleton, int32 nodeIndex, const Transform& value);
void SetNodeModelTransformation(SkeletonData& skeleton, int32 nodeIndex, const Transform& value, float weight = 1.0f);
Transform GetNodeWorldTransformation(const AnimGraphContext& context, SkeletonData& skeleton, int32 nodeIndex) const;
void SetNodeWorldTransformation(const AnimGraphContext& context, SkeletonData& skeleton, int32 nodeIndex, const Transform& value);
};
/// <summary>
@@ -270,6 +273,18 @@ public:
float Data[4];
};
struct SpringBonePhysicsBucket
{
uint64 LastUpdateFrame;
int32 StateDataStart;
};
struct SpringBonePhysicsDynamic
{
Vector3 CurrentPosition;
Vector3 PreviousPosition;
};
/// <summary>
/// The single data storage bucket for the instanced animation graph node. Used to store the node state (playback position, state, transition data).
/// </summary>
@@ -283,6 +298,7 @@ public:
StateMachineBucket StateMachine;
SlotBucket Slot;
InstanceDataBucket InstanceData;
SpringBonePhysicsBucket SpringBonePhysics;
};
};
@@ -322,6 +338,11 @@ public:
/// </summary>
Array<Bucket> State;
/// <summary>
/// The animation state extended data. Managed by the specific node types and shared for a whole instance. Can be resized at runtime.
/// </summary>
Array<byte> DynamicState;
/// <summary>
/// The per-node final transformations in actor local-space.
/// </summary>
@@ -368,6 +389,12 @@ public:
/// </summary>
void InvokeAnimEvents();
/// <summary>
/// Gets the world-space transformation of the animated object.
/// </summary>
/// <returns>Transformation from model/local space to world space.</returns>
Transform GetObjectTransform() const;
public:
// Anim Graph logic tracing feature that allows to collect insights of animations sampling and skeleton poses operations.
bool EnableTracing = false;
@@ -9,6 +9,7 @@
#include "Engine/Animations/AnimEvent.h"
#include "Engine/Animations/InverseKinematics.h"
#include "Engine/Level/Actors/AnimatedModel.h"
#include "Engine/Physics/Physics.h"
struct AnimSampleData
{
@@ -2513,6 +2514,145 @@ void AnimGraphExecutor::ProcessGroupAnimation(Box* boxBase, Node* nodeBase, Valu
value = *(Float4*)bucket.Data;
break;
}
// Spring Bone Physics
case 35:
{
// Get bucket
auto& bucket = context.Data->State[node->BucketIndex].SpringBonePhysics;
Transform objectTransform = context.Data->GetObjectTransform();
// Get input
auto input = tryGetValue(node->GetBox(1), Value::Null);
const auto endNodeIndex = node->Data.TransformNode.NodeIndex;
float weight = (float)tryGetValue(node->GetBox(2), node->Values[2]);
if (endNodeIndex < 0 || endNodeIndex >= _skeletonNodesCount || weight < ANIM_GRAPH_BLEND_THRESHOLD)
{
value = input;
break;
}
const auto nodes = node->GetNodes(this, input);
// Get parameters
weight = Math::Min(weight, 1.0f);
float deltaTime = context.DeltaTime;
int32 nodesCount = Math::Clamp((int32)node->Values[1] + 1, 1, _skeletonNodesCount);
float stiffness = (float)tryGetValue(node->GetBox(3), node->Values[3]);
float drag = (float)tryGetValue(node->GetBox(4), node->Values[4]);
float stretchLimit = (float)tryGetValue(node->GetBox(5), node->Values[5]) + 1.0f;
float gravityScale = (float)tryGetValue(node->GetBox(6), node->Values[6]);
Vector3 force = (Vector3)tryGetValue(node->GetBox(7), node->Values[7]) + Physics::GetGravity() * gravityScale;
// Get world-space transforms of the nodes (from root to end)
Array<int32, InlinedAllocation<8>> nodesIndices;
Array<Transform, InlinedAllocation<8>> nodesRef;
nodesIndices.Resize(nodesCount);
nodesRef.Resize(nodesCount);
auto& skeleton = _graph.BaseModel->Skeleton;
for (int32 i = nodesCount - 1, nodeIndex = endNodeIndex; i >= 0; i--)
{
nodesIndices[i] = nodeIndex;
nodeIndex = skeleton.Nodes[nodeIndex].ParentIndex;
}
nodesRef[0] = nodes->GetNodeWorldTransformation(context, skeleton, nodesIndices[0]); // Root comes from animation (incl. animated model movement)
for (int32 i = 1; i < nodesCount; i++)
nodesRef[i] = nodesRef[i - 1].LocalToWorld(nodes->Nodes[nodesIndices[i]]);
// Check if we reset the simulation (start from the reference pose)
bool reset = bucket.LastUpdateFrame < context.CurrentFrameIndex - 1 || context.CurrentFrameIndex == 1;
if (bucket.StateDataStart == -1)
{
// Allocate a dynamic state
bucket.StateDataStart = context.Data->DynamicState.Count();
context.Data->DynamicState.AddUninitialized(sizeof(AnimGraphInstanceData::SpringBonePhysicsDynamic) * nodesCount);
reset = true;
}
auto* dynamic = (AnimGraphInstanceData::SpringBonePhysicsDynamic*)&context.Data->DynamicState[bucket.StateDataStart];
if (reset)
{
// Initialize the simulation from the reference pose
for (int32 i = 0; i < nodesCount; i++)
dynamic[i].CurrentPosition = dynamic[i].PreviousPosition = nodesRef[i].Translation;
}
bucket.LastUpdateFrame = context.CurrentFrameIndex;
// Move each node by velocity and forces
dynamic[0].PreviousPosition = dynamic[0].CurrentPosition;
dynamic[0].CurrentPosition = nodesRef[0].Translation;
for (int32 i = 1; i < nodesCount; i++)
{
Vector3 position = dynamic[i].CurrentPosition;
Vector3 prevPosition = dynamic[i].PreviousPosition;
Vector3 refPosition = nodesRef[i].Translation;
// Stiffness force
Vector3 delta = (refPosition - position) * stiffness;
// Gravity and wind forces
delta += force * (deltaTime * deltaTime);
// Verlet integration
delta += (position - prevPosition) * (1 - drag);
dynamic[i].PreviousPosition = position;
dynamic[i].CurrentPosition = position + delta;
}
// Length constraints
for (int32 i = 1; i < nodesCount; i++)
{
Vector3 offset = dynamic[i].CurrentPosition - dynamic[i - 1].CurrentPosition;
Real dist = offset.Length();
Real boneLength = Vector3::Distance(nodesRef[i].Translation, nodesRef[i - 1].Translation);
Real maxBoneLength = boneLength * stretchLimit;
if (dist > maxBoneLength && dist > ANIM_GRAPH_BLEND_THRESHOLD)
{
Real scale = ((dist - maxBoneLength) / dist);
dynamic[i].CurrentPosition -= offset * scale;
}
}
// Update the nodes with the new transforms (move back from world-space to node-space)
Quaternion prevRotation = Quaternion::Identity;
for (int32 i = 0; i < nodesCount; i++)
{
Transform nodeTransform = nodesRef[i];
nodeTransform.Translation = dynamic[i].CurrentPosition;
// Move back from world-space to model-space
nodeTransform = objectTransform.WorldToLocal(nodeTransform);
if (i + 1 < nodesCount)
{
// Orient node to point to the next node in the chain
Vector3 childNewPos = objectTransform.WorldToLocal(dynamic[i + 1].CurrentPosition);
int32 childIndex = nodesIndices[i + 1];
Vector3 childRefPos = nodes->Nodes[childIndex].Translation;
Vector3 childOldPos = nodeTransform.LocalToWorld(childRefPos);
Vector3 oldDir = (childOldPos - nodeTransform.Translation).GetNormalized();
Vector3 newDir = (childNewPos - nodeTransform.Translation).GetNormalized();
nodeTransform.Orientation = Quaternion::FindBetween(oldDir, newDir) * nodeTransform.Orientation;
prevRotation = nodeTransform.Orientation;
}
else
{
// Tip (last) node copies the orientation of the parent
nodeTransform.Orientation = prevRotation;
}
nodes->SetNodeModelTransformation(skeleton, nodesIndices[i], nodeTransform, weight);
}
// When we blend between animated and simulated poses then fetch the final pose from the nodes instead of using the simulated pose directly
if (weight < 1.0f)
{
// TODO: optimize it by getting root node, then using LocalToWorld for following children
for (int32 i = 1; i < nodesCount; i++)
dynamic[i].CurrentPosition = nodes->GetNodeWorldTransformation(context, skeleton, nodesIndices[i]).Translation;
}
value = nodes;
break;
}
default:
break;
}