MonoGame + C# Procedural Animations!
So I have been looking for more MonoGame based tutorials on this, a lot of them that still exist are really good, but to many XNA and older MonoGame resources are old and full of dead links. Which is more frustrating than helpful. We had originally attempted this in Vulkan and after a week of getting absolutely nowhere, seemingly, I decided ok what if it is Vulkan? So I made the changes required quickly using Gemini to convert my Vulkan shader into one for DX11, 12 and OpenGL. All 3 worked basically as expected, DX12 with some extra steps, but I could get the animations rendering. So, we made the hard choice to port back to OpenGL for the time being. That said, lets get on with it!
We started by building out an importer using AssimpNet. This library gives you the ability to create your own FBX importer that, so far, works better than the built in aging XNA pipeline. Please keep in mind, I am sharing this as excerts from my own game engine. Some aspects are removed/changed.
using Assimp;
using Microsoft.Xna.Framework;
using StarLiteFramework.Models.Geometry.Imported;
using StarLiteFramework.Structs;
namespace StarLiteFramework.EngineIO
{
public class EngineAssetImporter
{
public static AnimatedModelData ImportSkinnedMesh(string filePath)
{
var importer = new AssimpContext();
var scene = importer.ImportFile(filePath, PostProcessSteps.Triangulate | PostProcessSteps.FlipUVs | PostProcessSteps.CalculateTangentSpace);
var mesh = scene.Meshes.FirstOrDefault(m => m.HasBones);
if (mesh == null) throw new Exception("CRITICAL: No rigged meshes found in this FBX.");
var modelData = new AnimatedModelData
{
BoneNameToIndex = new Dictionary<string, int>(),
BoneOffsetMatrices = new List<Microsoft.Xna.Framework.Matrix>()
};
List<Vector2>[] vertexWeights = new List<Vector2>[mesh.VertexCount];
for (int i = 0; i < mesh.VertexCount; i++) vertexWeights[i] = new List<Vector2>();
for (int b = 0; b < mesh.Bones.Count; b++)
{
var bone = mesh.Bones[b];
modelData.BoneNameToIndex[bone.Name] = b;
modelData.BoneOffsetMatrices.Add(ToXnaMatrix(bone.OffsetMatrix));
foreach (var weight in bone.VertexWeights)
{
vertexWeights[weight.VertexID].Add(new Vector2(b, weight.Weight));
}
}
Vector3 boundsMin = new Vector3(float.MaxValue);
Vector3 boundsMax = new Vector3(float.MinValue);
SkinnedVertex[] vertices = new SkinnedVertex[mesh.VertexCount];
for (int i = 0; i < mesh.VertexCount; i++)
{
var vw = vertexWeights[i].OrderByDescending(w => w.Y).Take(4).ToList();
Vector4 indices = Vector4.Zero;
Vector4 weights = Vector4.Zero;
float sum = 0f;
for (int j = 0; j < vw.Count; j++)
{
if (j == 0) { indices.X = vw[j].X; weights.X = vw[j].Y; }
if (j == 1) { indices.Y = vw[j].X; weights.Y = vw[j].Y; }
if (j == 2) { indices.Z = vw[j].X; weights.Z = vw[j].Y; }
if (j == 3) { indices.W = vw[j].X; weights.W = vw[j].Y; }
sum += vw[j].Y;
}
if (sum > 0) weights /= sum;
var pos = new Vector3(mesh.Vertices[i].X, mesh.Vertices[i].Y, mesh.Vertices[i].Z);
boundsMin = Vector3.Min(boundsMin, pos);
boundsMax = Vector3.Max(boundsMax, pos);
vertices[i] = new SkinnedVertex
{
Position = pos,
Normal = new Vector3(mesh.Normals[i].X, mesh.Normals[i].Y, mesh.Normals[i].Z),
TextureCoordinate = mesh.HasTextureCoords(0) ? new Vector2(mesh.TextureCoordinateChannels[0][i].X, mesh.TextureCoordinateChannels[0][i].Y) : Vector2.Zero,
BlendIndices = indices,
BlendWeights = weights
};
}
modelData.Vertices = vertices;
modelData.Indices = mesh.GetIndices();
modelData.BoundsMin = boundsMin;
modelData.BoundsMax = boundsMax;
modelData.RootNode = BuildHierarchy(scene.RootNode, null);
return modelData;
}
private static RigNode BuildHierarchy(Node assimpNode, RigNode parent)
{
var rigNode = new RigNode
{
Name = assimpNode.Name,
BaseLocalTransform = ToXnaMatrix(assimpNode.Transform),
Parent = parent
};
rigNode.CurrentLocalTransform = rigNode.BaseLocalTransform;
foreach (var child in assimpNode.Children)
{
rigNode.Children.Add(BuildHierarchy(child, rigNode));
}
return rigNode;
}
private static Microsoft.Xna.Framework.Matrix ToXnaMatrix(Assimp.Matrix4x4 m)
{
return new Microsoft.Xna.Framework.Matrix(
m.A1, m.B1, m.C1, m.D1,
m.A2, m.B2, m.C2, m.D2,
m.A3, m.B3, m.C3, m.D3,
m.A4, m.B4, m.C4, m.D4
);
}
}
}The above class allows us to import an FBX model into our projects. They do not need to be pre-compiled either! Allowing you more flexability using MonoGame! Never a bad thing IMO.
Now the animation part is still a work in progress for me. We have the jist built so far: Rendering the model, and applying procedural animations at runtime via code. We are working on more animations at this point and getting ready to roll this into our main project.
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using System.Runtime.InteropServices;
namespace StarLiteFramework.Structs
{
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct SkinnedVertex : IVertexType
{
public Vector3 Position;
public Vector3 Normal;
public Vector2 TextureCoordinate;
public Vector4 BlendIndices;
public Vector4 BlendWeights;
public static readonly VertexDeclaration VertexDeclaration = new VertexDeclaration(
new VertexElement(0, VertexElementFormat.Vector3, VertexElementUsage.Position, 0),
new VertexElement(12, VertexElementFormat.Vector3, VertexElementUsage.Normal, 0),
new VertexElement(24, VertexElementFormat.Vector2, VertexElementUsage.TextureCoordinate, 0),
new VertexElement(32, VertexElementFormat.Vector4, VertexElementUsage.TextureCoordinate, 1),
new VertexElement(48, VertexElementFormat.Vector4, VertexElementUsage.TextureCoordinate, 2)
);
VertexDeclaration IVertexType.VertexDeclaration => VertexDeclaration;
}
}
using Microsoft.Xna.Framework;
namespace StarLiteFramework.Models.Geometry.Imported
{
public class RigNode
{
public string Name;
public Matrix BaseLocalTransform;
public Matrix CurrentLocalTransform;
public Matrix GlobalTransform;
public RigNode Parent;
public List<RigNode> Children = new List<RigNode>();
}
}
using Microsoft.Xna.Framework;
using StarLiteFramework.Structs;
namespace StarLiteFramework.Models.Geometry.Imported
{
public class AnimatedModelData
{
public SkinnedVertex[] Vertices { get; set; }
public int[] Indices { get; set; }
public RigNode RootNode { get; set; }
public Dictionary<string, int> BoneNameToIndex { get; set; }
public List<Matrix> BoneOffsetMatrices { get; set; }
public Vector3 BoundsMin { get; set; }
public Vector3 BoundsMax { get; set; }
}
}
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using StarLiteFramework.Models.Geometry.Imported;
using StarLiteFramework.Structs;
namespace StarLiteFramework.Animation
{
public class Animator
{
public RigNode RootNode { get; private set; }
public Dictionary<string, int> BoneNameToIndex { get; private set; }
public Dictionary<int, RigNode> BoneIndexToNode { get; private set; }
public List<Matrix> BoneOffsetMatrices { get; private set; }
public Matrix[] ShaderBones { get; private set; }
public bool PlayIdle { get; set; } = false;
public bool PlayWalk { get; set; } = true;
public int TotalVertexCount { get; private set; }
public int WeightedVertexCount { get; private set; }
private float _animationTime;
private readonly AnimatedModelData _modelData;
// GPU Buffers & Effects
private VertexBuffer _vertexBuffer;
private IndexBuffer _indexBuffer;
private Effect _skinningEffect;
// CPU Verify Modes
private DynamicVertexBuffer _cpuVerifyVertexBuffer;
private BasicEffect _cpuVerifyEffect;
private VertexPositionColor[] _cpuVerifyVertices;
private int _sampleSpineVertexIndex = -1;
// Cached nodes for procedural animation
private RigNode _hips;
private RigNode _leftPelvis;
private RigNode _rightPelvis;
private RigNode _spine;
private RigNode _leftCollar;
private RigNode _rightCollar;
private RigNode _neck;
private RigNode _leftUpperArm;
private RigNode _rightUpperArm;
private RigNode _leftLowerArm;
private RigNode _rightLowerArm;
private RigNode _leftUpperLeg;
private RigNode _rightUpperLeg;
private RigNode _leftLowerLeg;
private RigNode _rightLowerLeg;
public Animator(AnimatedModelData modelData)
{
_modelData = modelData;
RootNode = modelData.RootNode;
BoneNameToIndex = modelData.BoneNameToIndex;
BoneOffsetMatrices = modelData.BoneOffsetMatrices;
BoneIndexToNode = new Dictionary<int, RigNode>();
ShaderBones = new Matrix[72];
for (int i = 0; i < 72; i++) ShaderBones[i] = Matrix.Identity;
MapBonesToNodes(RootNode);
// Core & Pelvis
_hips = GetNodeByName("Hips") ?? GetNodeByName("Pelvis");
_leftPelvis = GetNodeByName("pelvis.l") ?? GetNodeByName("LeftPelvis");
_rightPelvis = GetNodeByName("pelvis.r") ?? GetNodeByName("RightPelvis");
_spine = GetNodeByName("Spine");
_leftCollar = GetNodeByName("collar.l");
_rightCollar = GetNodeByName("collar.r");
_neck = GetNodeByName("Neck");
// Arms
_leftUpperArm = GetNodeByName("upperArm.l") ?? GetNodeByName("LeftArm");
_rightUpperArm = GetNodeByName("upperArm.r") ?? GetNodeByName("RightArm");
_leftLowerArm = GetNodeByName("lowerArm.l") ?? GetNodeByName("LeftForeArm");
_rightLowerArm = GetNodeByName("lowerArm.r") ?? GetNodeByName("RightForeArm");
// Legs
_leftUpperLeg = GetNodeByName("thigh.l") ?? GetNodeByName("LeftUpLeg");
_rightUpperLeg = GetNodeByName("thigh.r") ?? GetNodeByName("RightUpLeg");
_leftLowerLeg = GetNodeByName("shin.l") ?? GetNodeByName("LeftLeg");
_rightLowerLeg = GetNodeByName("shin.r") ?? GetNodeByName("RightLeg");
}
public void InitializeGraphics(GraphicsDevice graphicsDevice, Effect skinningEffect)
{
_skinningEffect = skinningEffect;
var vertices = _modelData.Vertices;
var indices = _modelData.Indices;
TotalVertexCount = vertices.Length;
WeightedVertexCount = vertices.Count(v => (v.BlendWeights.X + v.BlendWeights.Y + v.BlendWeights.Z + v.BlendWeights.W) > 0.0001f);
_vertexBuffer = new VertexBuffer(graphicsDevice, SkinnedVertex.VertexDeclaration, vertices.Length, BufferUsage.WriteOnly);
_vertexBuffer.SetData(vertices);
_indexBuffer = new IndexBuffer(graphicsDevice, IndexElementSize.ThirtyTwoBits, indices.Length, BufferUsage.WriteOnly);
_indexBuffer.SetData(indices);
_cpuVerifyVertices = new VertexPositionColor[vertices.Length];
_cpuVerifyVertexBuffer = new DynamicVertexBuffer(graphicsDevice, VertexPositionColor.VertexDeclaration, vertices.Length, BufferUsage.WriteOnly);
_cpuVerifyEffect = new BasicEffect(graphicsDevice)
{
VertexColorEnabled = true,
LightingEnabled = false
};
// Cache spine vertex for debugging
if (BoneNameToIndex.TryGetValue("Spine", out int spineBoneIdx))
{
float bestWeight = -1f;
for (int i = 0; i < vertices.Length; i++)
{
Vector4 idx = vertices[i].BlendIndices;
Vector4 w = vertices[i].BlendWeights;
float wForSpine = 0f;
if ((int)idx.X == spineBoneIdx) wForSpine = Math.Max(wForSpine, w.X);
if ((int)idx.Y == spineBoneIdx) wForSpine = Math.Max(wForSpine, w.Y);
if ((int)idx.Z == spineBoneIdx) wForSpine = Math.Max(wForSpine, w.Z);
if ((int)idx.W == spineBoneIdx) wForSpine = Math.Max(wForSpine, w.W);
if (wForSpine > bestWeight)
{
bestWeight = wForSpine;
_sampleSpineVertexIndex = i;
}
}
}
}
public void Update(float deltaTime)
{
_animationTime += deltaTime;
ResetTransforms();
ApplyIdleAnimation();
}
public void Draw(GraphicsDevice graphicsDevice, Matrix world, Matrix view, Matrix projection, bool useCpuVerify)
{
graphicsDevice.RasterizerState = RasterizerState.CullClockwise;
graphicsDevice.DepthStencilState = DepthStencilState.Default;
graphicsDevice.Indices = _indexBuffer;
if (useCpuVerify)
{
UpdateCpuVerifyMesh();
_cpuVerifyVertexBuffer.SetData(_cpuVerifyVertices, 0, _cpuVerifyVertices.Length, SetDataOptions.Discard);
_cpuVerifyEffect.World = world;
_cpuVerifyEffect.View = view;
_cpuVerifyEffect.Projection = projection;
graphicsDevice.SetVertexBuffer(_cpuVerifyVertexBuffer);
foreach (EffectPass pass in _cpuVerifyEffect.CurrentTechnique.Passes)
{
pass.Apply();
graphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, _indexBuffer.IndexCount / 3);
}
}
else
{
graphicsDevice.SetVertexBuffer(_vertexBuffer);
_skinningEffect.Parameters["World"].SetValue(world);
_skinningEffect.Parameters["View"].SetValue(view);
_skinningEffect.Parameters["Projection"].SetValue(projection);
_skinningEffect.Parameters["Bones"].SetValue(ShaderBones);
foreach (EffectPass pass in _skinningEffect.CurrentTechnique.Passes)
{
pass.Apply();
graphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, _indexBuffer.IndexCount / 3);
}
}
}
private void UpdateCpuVerifyMesh()
{
Vector3 lightDirection = Vector3.Normalize(new Vector3(-1.0f, -1.0f, 0.5f));
Vector3 baseColor = new Vector3(0.7f, 0.7f, 0.7f);
var bindVertices = _modelData.Vertices;
for (int i = 0; i < bindVertices.Length; i++)
{
var vertex = bindVertices[i];
Vector4 idx = vertex.BlendIndices;
Vector4 w = vertex.BlendWeights;
float weightSum = w.X + w.Y + w.Z + w.W;
Vector3 skinnedPos;
Vector3 skinnedNormal;
if (weightSum > 0.001f)
{
Matrix skin =
ShaderBones[(int)idx.X] * w.X +
ShaderBones[(int)idx.Y] * w.Y +
ShaderBones[(int)idx.Z] * w.Z +
ShaderBones[(int)idx.W] * w.W;
skinnedPos = Vector3.Transform(vertex.Position, skin);
skinnedNormal = Vector3.Normalize(Vector3.TransformNormal(vertex.Normal, skin));
}
else
{
skinnedPos = vertex.Position;
skinnedNormal = vertex.Normal;
}
float lightIntensity = Math.Max(0.25f, Vector3.Dot(skinnedNormal, -lightDirection));
Vector3 lit = baseColor * lightIntensity;
_cpuVerifyVertices[i] = new VertexPositionColor(skinnedPos, new Color(lit.X, lit.Y, lit.Z));
}
}
public Vector3 GetSpineSamplePosition(Matrix worldTransform)
{
if (_sampleSpineVertexIndex >= 0)
{
var v = _modelData.Vertices[_sampleSpineVertexIndex];
Vector4 idx = v.BlendIndices;
Vector4 w = v.BlendWeights;
Matrix skin =
ShaderBones[(int)idx.X] * w.X +
ShaderBones[(int)idx.Y] * w.Y +
ShaderBones[(int)idx.Z] * w.Z +
ShaderBones[(int)idx.W] * w.W;
return Vector3.Transform(v.Position, skin * worldTransform);
}
return Vector3.Zero;
}
private void ApplyIdleAnimation()
{
float breathingSine = (float)Math.Sin(_animationTime * 2.0f);
float breath01 = (breathingSine + 1f) * 0.5f;
float lowerSpinePitch = breath01 * 0.015f;
float upperSpinePitch = breath01 * 0.025f;
float neckCounterPitch = -breath01 * 0.02f;
if (_spine != null)
_spine.CurrentLocalTransform = Matrix.CreateRotationX(lowerSpinePitch) * _spine.BaseLocalTransform;
if (_leftCollar != null)
_leftCollar.CurrentLocalTransform = Matrix.CreateRotationX(upperSpinePitch) * _leftCollar.BaseLocalTransform;
if (_neck != null)
_neck.CurrentLocalTransform = Matrix.CreateRotationX(neckCounterPitch) * _neck.BaseLocalTransform;
float armDropAngle = MathHelper.ToRadians(75f);
float armForwardAngle = MathHelper.ToRadians(15f);
float armSwayAngle = breath01 * 0.04f;
if (_leftUpperArm != null)
{
Matrix leftIdle = Matrix.CreateRotationX(armForwardAngle + armSwayAngle) * Matrix.CreateRotationZ(-armDropAngle);
_leftUpperArm.CurrentLocalTransform = leftIdle * _leftUpperArm.BaseLocalTransform;
}
if (_rightUpperArm != null)
{
Matrix rightIdle = Matrix.CreateRotationX(armForwardAngle + armSwayAngle) * Matrix.CreateRotationZ(armDropAngle);
_rightUpperArm.CurrentLocalTransform = rightIdle * _rightUpperArm.BaseLocalTransform;
}
float elbowBend = MathHelper.ToRadians(5f) + (breath01 * 0.015f);
if (_leftLowerArm != null)
_leftLowerArm.CurrentLocalTransform = Matrix.CreateRotationX(elbowBend) * _leftLowerArm.BaseLocalTransform;
if (_rightLowerArm != null)
_rightLowerArm.CurrentLocalTransform = Matrix.CreateRotationX(elbowBend) * _rightLowerArm.BaseLocalTransform;
}
private void MapBonesToNodes(RigNode node)
{
string cleanName = CleanBoneName(node.Name);
var match = BoneNameToIndex.FirstOrDefault(k => CleanBoneName(k.Key).Equals(cleanName, StringComparison.OrdinalIgnoreCase));
if (match.Key != null)
{
BoneIndexToNode[match.Value] = node;
}
foreach (var child in node.Children)
{
MapBonesToNodes(child);
}
}
public RigNode GetNodeByName(string name)
{
return FindNode(RootNode, name);
}
private RigNode FindNode(RigNode node, string name)
{
if (CleanBoneName(node.Name).Equals(name, StringComparison.OrdinalIgnoreCase))
return node;
foreach (var child in node.Children)
{
var found = FindNode(child, name);
if (found != null) return found;
}
return null;
}
public void ResetTransforms()
{
ResetNodeTransforms(RootNode);
}
private void ResetNodeTransforms(RigNode node)
{
node.CurrentLocalTransform = node.BaseLocalTransform;
foreach (var child in node.Children)
ResetNodeTransforms(child);
}
public void UpdateMatrices(Matrix parentWorldTransform)
{
CalculateGlobalTransforms(RootNode, parentWorldTransform);
foreach (var kvp in BoneIndexToNode)
{
int boneIndex = kvp.Key;
RigNode node = kvp.Value;
ShaderBones[boneIndex] = BoneOffsetMatrices[boneIndex] * node.GlobalTransform;
}
}
private void CalculateGlobalTransforms(RigNode node, Matrix parentGlobal)
{
node.GlobalTransform = node.CurrentLocalTransform * parentGlobal;
foreach (var child in node.Children)
CalculateGlobalTransforms(child, node.GlobalTransform);
}
private string CleanBoneName(string name)
{
return name.Replace("mixamorig:", "").Replace("Armature_", "").Trim();
}
}
}To unpack everything quickly:
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using System.Runtime.InteropServices;
namespace StarLiteFramework.Structs
{
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct SkinnedVertex : IVertexType
{
public Vector3 Position;
public Vector3 Normal;
public Vector2 TextureCoordinate;
public Vector4 BlendIndices;
public Vector4 BlendWeights;
public static readonly VertexDeclaration VertexDeclaration = new VertexDeclaration(
new VertexElement(0, VertexElementFormat.Vector3, VertexElementUsage.Position, 0),
new VertexElement(12, VertexElementFormat.Vector3, VertexElementUsage.Normal, 0),
new VertexElement(24, VertexElementFormat.Vector2, VertexElementUsage.TextureCoordinate, 0),
new VertexElement(32, VertexElementFormat.Vector4, VertexElementUsage.TextureCoordinate, 1),
new VertexElement(48, VertexElementFormat.Vector4, VertexElementUsage.TextureCoordinate, 2)
);
VertexDeclaration IVertexType.VertexDeclaration => VertexDeclaration;
}
}
using Microsoft.Xna.Framework;
namespace StarLiteFramework.Models.Geometry.Imported
{
public class RigNode
{
public string Name;
public Matrix BaseLocalTransform;
public Matrix CurrentLocalTransform;
public Matrix GlobalTransform;
public RigNode Parent;
public List<RigNode> Children = new List<RigNode>();
}
}
using Microsoft.Xna.Framework;
using StarLiteFramework.Structs;
namespace StarLiteFramework.Models.Geometry.Imported
{
public class AnimatedModelData
{
public SkinnedVertex[] Vertices { get; set; }
public int[] Indices { get; set; }
public RigNode RootNode { get; set; }
public Dictionary<string, int> BoneNameToIndex { get; set; }
public List<Matrix> BoneOffsetMatrices { get; set; }
public Vector3 BoundsMin { get; set; }
public Vector3 BoundsMax { get; set; }
}
}The above code are the models required to store the rig and models data. I kept everything as close to biuler plate as I could for simplicity early on. Animated model data holds all of the models actual meta data. All of the vertices, root, etc are all contained in here. The rig represents a single node or bone in the rig, this container holds all of that bones current data. The final part is the SkinnedVertex, which defines the data for a single vertex.
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using StarLiteFramework.Models.Geometry.Imported;
using StarLiteFramework.Structs;
namespace StarLiteFramework.Animation
{
public class Animator
{
public RigNode RootNode { get; private set; }
public Dictionary<string, int> BoneNameToIndex { get; private set; }
public Dictionary<int, RigNode> BoneIndexToNode { get; private set; }
public List<Matrix> BoneOffsetMatrices { get; private set; }
public Matrix[] ShaderBones { get; private set; }
public bool PlayIdle { get; set; } = false;
public bool PlayWalk { get; set; } = true;
public int TotalVertexCount { get; private set; }
public int WeightedVertexCount { get; private set; }
private float _animationTime;
private readonly AnimatedModelData _modelData;
// GPU Buffers & Effects
private VertexBuffer _vertexBuffer;
private IndexBuffer _indexBuffer;
private Effect _skinningEffect;
// CPU Verify Modes
private DynamicVertexBuffer _cpuVerifyVertexBuffer;
private BasicEffect _cpuVerifyEffect;
private VertexPositionColor[] _cpuVerifyVertices;
private int _sampleSpineVertexIndex = -1;
// Cached nodes for procedural animation
private RigNode _hips;
private RigNode _leftPelvis;
private RigNode _rightPelvis;
private RigNode _spine;
private RigNode _leftCollar;
private RigNode _rightCollar;
private RigNode _neck;
private RigNode _leftUpperArm;
private RigNode _rightUpperArm;
private RigNode _leftLowerArm;
private RigNode _rightLowerArm;
private RigNode _leftUpperLeg;
private RigNode _rightUpperLeg;
private RigNode _leftLowerLeg;
private RigNode _rightLowerLeg;
public Animator(AnimatedModelData modelData)
{
_modelData = modelData;
RootNode = modelData.RootNode;
BoneNameToIndex = modelData.BoneNameToIndex;
BoneOffsetMatrices = modelData.BoneOffsetMatrices;
BoneIndexToNode = new Dictionary<int, RigNode>();
ShaderBones = new Matrix[72];
for (int i = 0; i < 72; i++) ShaderBones[i] = Matrix.Identity;
MapBonesToNodes(RootNode);
// Core & Pelvis
_hips = GetNodeByName("Hips") ?? GetNodeByName("Pelvis");
_leftPelvis = GetNodeByName("pelvis.l") ?? GetNodeByName("LeftPelvis");
_rightPelvis = GetNodeByName("pelvis.r") ?? GetNodeByName("RightPelvis");
_spine = GetNodeByName("Spine");
_leftCollar = GetNodeByName("collar.l");
_rightCollar = GetNodeByName("collar.r");
_neck = GetNodeByName("Neck");
// Arms
_leftUpperArm = GetNodeByName("upperArm.l") ?? GetNodeByName("LeftArm");
_rightUpperArm = GetNodeByName("upperArm.r") ?? GetNodeByName("RightArm");
_leftLowerArm = GetNodeByName("lowerArm.l") ?? GetNodeByName("LeftForeArm");
_rightLowerArm = GetNodeByName("lowerArm.r") ?? GetNodeByName("RightForeArm");
// Legs
_leftUpperLeg = GetNodeByName("thigh.l") ?? GetNodeByName("LeftUpLeg");
_rightUpperLeg = GetNodeByName("thigh.r") ?? GetNodeByName("RightUpLeg");
_leftLowerLeg = GetNodeByName("shin.l") ?? GetNodeByName("LeftLeg");
_rightLowerLeg = GetNodeByName("shin.r") ?? GetNodeByName("RightLeg");
}
public void InitializeGraphics(GraphicsDevice graphicsDevice, Effect skinningEffect)
{
_skinningEffect = skinningEffect;
var vertices = _modelData.Vertices;
var indices = _modelData.Indices;
TotalVertexCount = vertices.Length;
WeightedVertexCount = vertices.Count(v => (v.BlendWeights.X + v.BlendWeights.Y + v.BlendWeights.Z + v.BlendWeights.W) > 0.0001f);
_vertexBuffer = new VertexBuffer(graphicsDevice, SkinnedVertex.VertexDeclaration, vertices.Length, BufferUsage.WriteOnly);
_vertexBuffer.SetData(vertices);
_indexBuffer = new IndexBuffer(graphicsDevice, IndexElementSize.ThirtyTwoBits, indices.Length, BufferUsage.WriteOnly);
_indexBuffer.SetData(indices);
_cpuVerifyVertices = new VertexPositionColor[vertices.Length];
_cpuVerifyVertexBuffer = new DynamicVertexBuffer(graphicsDevice, VertexPositionColor.VertexDeclaration, vertices.Length, BufferUsage.WriteOnly);
_cpuVerifyEffect = new BasicEffect(graphicsDevice)
{
VertexColorEnabled = true,
LightingEnabled = false
};
// Cache spine vertex for debugging
if (BoneNameToIndex.TryGetValue("Spine", out int spineBoneIdx))
{
float bestWeight = -1f;
for (int i = 0; i < vertices.Length; i++)
{
Vector4 idx = vertices[i].BlendIndices;
Vector4 w = vertices[i].BlendWeights;
float wForSpine = 0f;
if ((int)idx.X == spineBoneIdx) wForSpine = Math.Max(wForSpine, w.X);
if ((int)idx.Y == spineBoneIdx) wForSpine = Math.Max(wForSpine, w.Y);
if ((int)idx.Z == spineBoneIdx) wForSpine = Math.Max(wForSpine, w.Z);
if ((int)idx.W == spineBoneIdx) wForSpine = Math.Max(wForSpine, w.W);
if (wForSpine > bestWeight)
{
bestWeight = wForSpine;
_sampleSpineVertexIndex = i;
}
}
}
}
public void Update(float deltaTime)
{
_animationTime += deltaTime;
ResetTransforms();
ApplyIdleAnimation();
}
public void Draw(GraphicsDevice graphicsDevice, Matrix world, Matrix view, Matrix projection, bool useCpuVerify)
{
graphicsDevice.RasterizerState = RasterizerState.CullClockwise;
graphicsDevice.DepthStencilState = DepthStencilState.Default;
graphicsDevice.Indices = _indexBuffer;
if (useCpuVerify)
{
UpdateCpuVerifyMesh();
_cpuVerifyVertexBuffer.SetData(_cpuVerifyVertices, 0, _cpuVerifyVertices.Length, SetDataOptions.Discard);
_cpuVerifyEffect.World = world;
_cpuVerifyEffect.View = view;
_cpuVerifyEffect.Projection = projection;
graphicsDevice.SetVertexBuffer(_cpuVerifyVertexBuffer);
foreach (EffectPass pass in _cpuVerifyEffect.CurrentTechnique.Passes)
{
pass.Apply();
graphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, _indexBuffer.IndexCount / 3);
}
}
else
{
graphicsDevice.SetVertexBuffer(_vertexBuffer);
_skinningEffect.Parameters["World"].SetValue(world);
_skinningEffect.Parameters["View"].SetValue(view);
_skinningEffect.Parameters["Projection"].SetValue(projection);
_skinningEffect.Parameters["Bones"].SetValue(ShaderBones);
foreach (EffectPass pass in _skinningEffect.CurrentTechnique.Passes)
{
pass.Apply();
graphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, _indexBuffer.IndexCount / 3);
}
}
}
private void UpdateCpuVerifyMesh()
{
Vector3 lightDirection = Vector3.Normalize(new Vector3(-1.0f, -1.0f, 0.5f));
Vector3 baseColor = new Vector3(0.7f, 0.7f, 0.7f);
var bindVertices = _modelData.Vertices;
for (int i = 0; i < bindVertices.Length; i++)
{
var vertex = bindVertices[i];
Vector4 idx = vertex.BlendIndices;
Vector4 w = vertex.BlendWeights;
float weightSum = w.X + w.Y + w.Z + w.W;
Vector3 skinnedPos;
Vector3 skinnedNormal;
if (weightSum > 0.001f)
{
Matrix skin =
ShaderBones[(int)idx.X] * w.X +
ShaderBones[(int)idx.Y] * w.Y +
ShaderBones[(int)idx.Z] * w.Z +
ShaderBones[(int)idx.W] * w.W;
skinnedPos = Vector3.Transform(vertex.Position, skin);
skinnedNormal = Vector3.Normalize(Vector3.TransformNormal(vertex.Normal, skin));
}
else
{
skinnedPos = vertex.Position;
skinnedNormal = vertex.Normal;
}
float lightIntensity = Math.Max(0.25f, Vector3.Dot(skinnedNormal, -lightDirection));
Vector3 lit = baseColor * lightIntensity;
_cpuVerifyVertices[i] = new VertexPositionColor(skinnedPos, new Color(lit.X, lit.Y, lit.Z));
}
}
public Vector3 GetSpineSamplePosition(Matrix worldTransform)
{
if (_sampleSpineVertexIndex >= 0)
{
var v = _modelData.Vertices[_sampleSpineVertexIndex];
Vector4 idx = v.BlendIndices;
Vector4 w = v.BlendWeights;
Matrix skin =
ShaderBones[(int)idx.X] * w.X +
ShaderBones[(int)idx.Y] * w.Y +
ShaderBones[(int)idx.Z] * w.Z +
ShaderBones[(int)idx.W] * w.W;
return Vector3.Transform(v.Position, skin * worldTransform);
}
return Vector3.Zero;
}
private void ApplyIdleAnimation()
{
float breathingSine = (float)Math.Sin(_animationTime * 2.0f);
float breath01 = (breathingSine + 1f) * 0.5f;
float lowerSpinePitch = breath01 * 0.015f;
float upperSpinePitch = breath01 * 0.025f;
float neckCounterPitch = -breath01 * 0.02f;
if (_spine != null)
_spine.CurrentLocalTransform = Matrix.CreateRotationX(lowerSpinePitch) * _spine.BaseLocalTransform;
if (_leftCollar != null)
_leftCollar.CurrentLocalTransform = Matrix.CreateRotationX(upperSpinePitch) * _leftCollar.BaseLocalTransform;
if (_neck != null)
_neck.CurrentLocalTransform = Matrix.CreateRotationX(neckCounterPitch) * _neck.BaseLocalTransform;
float armDropAngle = MathHelper.ToRadians(75f);
float armForwardAngle = MathHelper.ToRadians(15f);
float armSwayAngle = breath01 * 0.04f;
if (_leftUpperArm != null)
{
Matrix leftIdle = Matrix.CreateRotationX(armForwardAngle + armSwayAngle) * Matrix.CreateRotationZ(-armDropAngle);
_leftUpperArm.CurrentLocalTransform = leftIdle * _leftUpperArm.BaseLocalTransform;
}
if (_rightUpperArm != null)
{
Matrix rightIdle = Matrix.CreateRotationX(armForwardAngle + armSwayAngle) * Matrix.CreateRotationZ(armDropAngle);
_rightUpperArm.CurrentLocalTransform = rightIdle * _rightUpperArm.BaseLocalTransform;
}
float elbowBend = MathHelper.ToRadians(5f) + (breath01 * 0.015f);
if (_leftLowerArm != null)
_leftLowerArm.CurrentLocalTransform = Matrix.CreateRotationX(elbowBend) * _leftLowerArm.BaseLocalTransform;
if (_rightLowerArm != null)
_rightLowerArm.CurrentLocalTransform = Matrix.CreateRotationX(elbowBend) * _rightLowerArm.BaseLocalTransform;
}
private void MapBonesToNodes(RigNode node)
{
string cleanName = CleanBoneName(node.Name);
var match = BoneNameToIndex.FirstOrDefault(k => CleanBoneName(k.Key).Equals(cleanName, StringComparison.OrdinalIgnoreCase));
if (match.Key != null)
{
BoneIndexToNode[match.Value] = node;
}
foreach (var child in node.Children)
{
MapBonesToNodes(child);
}
}
public RigNode GetNodeByName(string name)
{
return FindNode(RootNode, name);
}
private RigNode FindNode(RigNode node, string name)
{
if (CleanBoneName(node.Name).Equals(name, StringComparison.OrdinalIgnoreCase))
return node;
foreach (var child in node.Children)
{
var found = FindNode(child, name);
if (found != null) return found;
}
return null;
}
public void ResetTransforms()
{
ResetNodeTransforms(RootNode);
}
private void ResetNodeTransforms(RigNode node)
{
node.CurrentLocalTransform = node.BaseLocalTransform;
foreach (var child in node.Children)
ResetNodeTransforms(child);
}
public void UpdateMatrices(Matrix parentWorldTransform)
{
CalculateGlobalTransforms(RootNode, parentWorldTransform);
foreach (var kvp in BoneIndexToNode)
{
int boneIndex = kvp.Key;
RigNode node = kvp.Value;
ShaderBones[boneIndex] = BoneOffsetMatrices[boneIndex] * node.GlobalTransform;
}
}
private void CalculateGlobalTransforms(RigNode node, Matrix parentGlobal)
{
node.GlobalTransform = node.CurrentLocalTransform * parentGlobal;
foreach (var child in node.Children)
CalculateGlobalTransforms(child, node.GlobalTransform);
}
private string CleanBoneName(string name)
{
return name.Replace("mixamorig:", "").Replace("Armature_", "").Trim();
}
}
}The animator class does what it says, it just lets me apply animations to the rig/model stored in the models we discussed above. With the ability to rotate the camera and move the model around for better viewing.
Get Matt Perley’s stories in your inbox
Join Medium for free to get updates from this writer.
Now to complete my “Tutorial”, here is the HLSL shader required to make this work.
#if OPENGL
#define SV_POSITION POSITION
#define SV_TARGET COLOR0
#define VS_SHADERMODEL vs_3_0
#define PS_SHADERMODEL ps_3_0
#else
#define SV_POSITION SV_Position
#define SV_TARGET SV_Target
#define VS_SHADERMODEL vs_6_0
#define PS_SHADERMODEL ps_6_0
#endif
matrix World;
matrix View;
matrix Projection;
matrix Bones[72];
struct VertexShaderInput
{
float4 Position : POSITION0;
float3 Normal : NORMAL0;
float2 TexCoord : TEXCOORD0;
float4 BlendIndices : TEXCOORD1;
float4 BlendWeights : TEXCOORD2;
};
struct VertexShaderOutput
{
float4 Position : SV_POSITION;
float4 Color : COLOR0;
};
VertexShaderOutput MainVS(in VertexShaderInput input)
{
VertexShaderOutput output = (VertexShaderOutput) 0;
int4 indices = (int4) input.BlendIndices;
matrix skinTransform =
Bones[indices.x] * input.BlendWeights.x +
Bones[indices.y] * input.BlendWeights.y +
Bones[indices.z] * input.BlendWeights.z +
Bones[indices.w] * input.BlendWeights.w;
float weightSum = input.BlendWeights.x + input.BlendWeights.y + input.BlendWeights.z + input.BlendWeights.w;
float4 skinPosition = (weightSum > 0.001f) ? mul(input.Position, skinTransform) : input.Position;
float3 skinNormal = (weightSum > 0.001f) ? mul(input.Normal, (float3x3) skinTransform) : input.Normal;
skinNormal = normalize(skinNormal);
float4 worldPosition = mul(skinPosition, World);
float4 viewPosition = mul(worldPosition, View);
output.Position = mul(viewPosition, Projection);
float3 worldNormal = normalize(mul(skinNormal, (float3x3) World));
float3 lightDirection = normalize(float3(-1.0f, -1.0f, 0.5f));
float lightIntensity = max(0.25f, dot(worldNormal, -lightDirection));
float3 baseColor = float3(0.7f, 0.7f, 0.7f);
output.Color = float4(baseColor * lightIntensity, 1.0f);
return output;
}
float4 MainPS(VertexShaderOutput input) : SV_TARGET
{
return input.Color;
}
technique Skinning
{
pass P0
{
VertexShader = compile VS_SHADERMODEL MainVS();
PixelShader = compile PS_SHADERMODEL MainPS();
}
}Now keep in mind you will have to change bone names to match your own models and arrays. I hope this helps someone else, this is a mix of a bunch of code I have come across online. Monogames potential feels endless, it just depends on figuring out a few key components.
Here is the Game1 file:
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using StarLiteFramework.Animation;
using StarLiteFramework.EngineIO;
using StarLiteFramework.Models.Geometry.Imported;
using System;
namespace RockGenerationDemo
{
public class OpenGLAnimDemo : Game
{
private GraphicsDeviceManager _graphics;
private AnimatedModelData _modelData;
private Animator _animator;
private bool _cpuVerifyMode = false;
private float _titleUpdateTimer;
private Matrix _world, _view, _projection;
private Vector3 _cameraTarget = Vector3.Zero;
private Vector3 _rawCenter = Vector3.Zero;
private float _maxDimension = 1f;
private float _modelPitch = 0f;
private float _modelYaw = 0f;
private MouseState _previousMouseState;
private KeyboardState _previousKeyboardState;
private float _cameraDistance = 10f;
private Vector3 _modelPosition = Vector3.Zero;
public OpenGLAnimDemo()
{
_graphics = new GraphicsDeviceManager(this)
{
PreferredBackBufferWidth = 1280,
PreferredBackBufferHeight = 720,
PreferMultiSampling = true,
//IsFullScreen = true
};
Content.RootDirectory = "Content";
IsMouseVisible = true;
}
protected override void Initialize()
{
_world = Matrix.Identity;
base.Initialize();
}
protected override void LoadContent()
{
_modelData = EngineAssetImporter.ImportSkinnedMesh("Content/Characters/Animation_Dummy/msm.fbx");
_animator = new Animator(_modelData);
Effect skinningEffect = Content.Load<Effect>("CustomSkinnedEffect");
_animator.InitializeGraphics(GraphicsDevice, skinningEffect);
SetupCamera();
}
private void SetupCamera()
{
Vector3 rawSize = _modelData.BoundsMax - _modelData.BoundsMin;
_rawCenter = (_modelData.BoundsMin + _modelData.BoundsMax) * 0.5f;
_maxDimension = Math.Max(rawSize.X, Math.Max(rawSize.Y, rawSize.Z));
if (_maxDimension < 0.0001f) _maxDimension = 1f;
_cameraTarget = _rawCenter;
_cameraDistance = Math.Max(_maxDimension * 2.8f, 15f);
float nearPlane = MathHelper.Clamp(_maxDimension * 0.005f, 0.05f, 1.0f);
float farPlane = Math.Max(5000f, _maxDimension * 50f);
_projection = Matrix.CreatePerspectiveFieldOfView(
MathHelper.PiOver4,
GraphicsDevice.Viewport.AspectRatio,
nearPlane,
farPlane
);
UpdateViewMatrix();
}
private void UpdateViewMatrix()
{
Vector3 eyePosition = _cameraTarget + new Vector3(0, 0, _cameraDistance);
_view = Matrix.CreateLookAt(eyePosition, _cameraTarget, Vector3.Up);
}
protected override void Update(GameTime gameTime)
{
float deltaTime = (float)gameTime.ElapsedGameTime.TotalSeconds;
KeyboardState kstate = Keyboard.GetState();
float rotationSpeed = 3.5f;
if (kstate.IsKeyDown(Keys.Left)) _modelYaw += rotationSpeed * deltaTime;
if (kstate.IsKeyDown(Keys.Right)) _modelYaw -= rotationSpeed * deltaTime;
if (kstate.IsKeyDown(Keys.Up)) _modelPitch += rotationSpeed * deltaTime;
if (kstate.IsKeyDown(Keys.Down)) _modelPitch -= rotationSpeed * deltaTime;
if (kstate.IsKeyDown(Keys.V) && !_previousKeyboardState.IsKeyDown(Keys.V))
_cpuVerifyMode = !_cpuVerifyMode;
MouseState mstate = Mouse.GetState();
int scrollDelta = mstate.ScrollWheelValue - _previousMouseState.ScrollWheelValue;
if (scrollDelta != 0)
{
float zoomStep = Math.Max(0.01f, _cameraDistance * 0.001f);
_cameraDistance -= scrollDelta * zoomStep;
_cameraDistance = MathHelper.Clamp(_cameraDistance, 0.1f, 10000f);
UpdateViewMatrix();
}
if (mstate.LeftButton == ButtonState.Pressed)
{
_modelYaw -= (mstate.X - _previousMouseState.X) * 0.01f;
_modelPitch -= (mstate.Y - _previousMouseState.Y) * 0.01f;
}
if (mstate.RightButton == ButtonState.Pressed)
{
float panSpeed = Math.Max(0.01f, _cameraDistance * 0.0012f);
_modelPosition.X += (mstate.X - _previousMouseState.X) * panSpeed;
_modelPosition.Y -= (mstate.Y - _previousMouseState.Y) * panSpeed;
}
_previousMouseState = mstate;
_previousKeyboardState = kstate;
_world = Matrix.CreateTranslation(-_rawCenter) *
Matrix.CreateRotationY(_modelYaw) *
Matrix.CreateRotationX(_modelPitch) *
Matrix.CreateTranslation(_rawCenter + _modelPosition);
_animator.Update(deltaTime);
_animator.UpdateMatrices(Matrix.Identity);
_titleUpdateTimer += deltaTime;
if (_titleUpdateTimer >= 0.1f)
{
_titleUpdateTimer = 0f;
Vector3 samplePosWorld = _animator.GetSpineSamplePosition(_world);
Window.Title = $"Bones: {_animator.BoneIndexToNode.Count}/{_animator.BoneNameToIndex.Count} | " +
$"Weighted: {_animator.WeightedVertexCount}/{_animator.TotalVertexCount} | " +
$"Mode: {(_cpuVerifyMode ? "CPU(V)" : "GPU")} | " +
$"SampleVtx: ({samplePosWorld.X:F2},{samplePosWorld.Y:F2},{samplePosWorld.Z:F2}) | " +
$"CamDist: {_cameraDistance:F2}";
}
base.Update(gameTime);
}
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(Color.CornflowerBlue);
_animator.Draw(GraphicsDevice, _world, _view, _projection, _cpuVerifyMode);
base.Draw(gameTime);
}
}
}This handles the rest of the processing for in this example. You can see how the effect is applied in the Draw function. How to prepare the model data and everything else shown in this demo.
As I said, I hope this helps. Until next time, please take care!
EDIT: I updated the code, as I have made some more changes.







