Static utility that generates AE_FOOTSTEP animation events from solved per-frame skeleton poses by detecting foot plant contacts for left and right foot chains. It computes ankle world positions, estimates ground, finds vertical contact ranges, filters by prior lift, and emits sorted AnimEventEntry objects skipping frame-0 plants.
#nullable enable annotations
using System;
using System.Collections.Generic;
using System.Numerics;
using TextToAnimation.Vmdl;
using TextToAnimation.Maths;
using SkeletonModel = TextToAnimation.Rig.Skeleton;
namespace TextToAnimation.Processing;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/TextToAnimation/Assembly.cs)
/// <summary>
/// Generates <c>AE_FOOTSTEP</c> <see cref="AnimEventEntry"/> lists from foot-plant detection
/// on a solved TARGET clip: each settled contact after a lift is the touchdown
/// moment, so it becomes one footstep event for that foot.
/// </summary>
/// <remarks>
/// <para><b>Shipped-data findings</b> (dev fixture
/// <c>citizen_animationlist.vmdl_prefab</c>): every one of the 28 shipped AnimEvent nodes is
/// <c>event_class = "AE_FOOTSTEP"</c> with an <c>event_keys</c> object of exactly
/// <c>Attachment = "foot_L"</c>/<c>"foot_R"</c>, <c>Foot = "0"</c> (left) / <c>"1"</c>
/// (right, a STRING) and <c>Volume = 0.7</c>. The generated events replicate that shape
/// verbatim, including the side encoding.</para>
/// <para><b>Frame-0 plants are skipped</b>: a plant interval that begins on the clip's first
/// frame means the foot was ALREADY planted when the clip starts — no touchdown happened
/// inside the clip, and on looping clips the same physical step would otherwise fire twice
/// (once at the wrap-around touchdown near the clip end, once at frame 0).</para>
/// </remarks>
public static class FootstepEvents
{
/// <summary>The Source 2 footstep anim-event class (the only event class present in the
/// shipped citizen animation data).</summary>
public const string FootstepEventClass = "AE_FOOTSTEP";
/// <summary>The constant volume used by every shipped footstep event.</summary>
public const double FootstepVolume = 0.7;
/// <summary>
/// Detects vertical settling near the ground after a foot lift and returns one
/// <c>AE_FOOTSTEP</c> per contact (frame-0 contacts skipped), merged in frame order.
/// Horizontal sliding is allowed for in-place locomotion; cleanup still uses its
/// stricter world-speed detector. Without explicit options, minimum contact duration
/// scales with sample rate (0.1 seconds).
/// </summary>
/// <param name="frames">Solved per-frame local transforms (target skeleton bone order).</param>
/// <param name="skeleton">Target skeleton the frames are expressed against.</param>
/// <param name="left">Left leg chain (target bone indices).</param>
/// <param name="right">Right leg chain (target bone indices).</param>
/// <param name="up">Target character up direction.</param>
/// <param name="fps">Clip sample rate.</param>
/// <param name="options">Plant-detection tunables; null = defaults (callers rescale the
/// cm-tuned thresholds for engine-space rigs, like the foot-plant cleanup does).</param>
public static List<AnimEventEntry> Generate(
List<XForm[]> frames,
SkeletonModel skeleton,
FootChain left,
FootChain right,
Vector3 up,
float fps,
FootPlantOptions? options = null)
{
ArgumentNullException.ThrowIfNull(frames);
ArgumentNullException.ThrowIfNull(skeleton);
ArgumentNullException.ThrowIfNull(left);
ArgumentNullException.ThrowIfNull(right);
var events = new List<AnimEventEntry>();
if (frames.Count < 2 || !float.IsFinite(fps) || fps <= 0 || !float.IsFinite(up.LengthSquared()) || up.LengthSquared() < 1e-12f)
return events;
up = Vector3.Normalize(up);
options ??= new FootPlantOptions
{
// Same minimum contact time at every sample rate (three frames at 30 Hz).
MinPlantFrames = Math.Max(2, (int)MathF.Ceiling(fps * 0.1f)),
};
var ankleL = FootPlant.AnkleWorldPositions(frames, skeleton, left.Ankle);
var ankleR = FootPlant.AnkleWorldPositions(frames, skeleton, right.Ankle);
var ground = FootPlant.EstimateGround(ankleL, ankleR, up);
AddFoot(events, Contacts(ankleL, up, ground, fps, options), attachment: "foot_L", foot: "0");
AddFoot(events, Contacts(ankleR, up, ground, fps, options), attachment: "foot_R", foot: "1");
// Stable frame ordering across both feet (List.Sort is unstable; the comparer breaks
// frame ties on the side key so the result is deterministic).
events.Sort((a, b) => a.Frame != b.Frame
? a.Frame.CompareTo(b.Frame)
: string.CompareOrdinal(a.Foot, b.Foot));
return events;
}
// A planted foot slides horizontally in an in-place clip. Detect vertical settling
// near the floor instead, but require a real lift before another touchdown. This
// also prevents horizontal speed jitter from generating repeated step sounds.
private static List<FrameRange> Contacts(Vector3[] ankle, Vector3 up, float ground, float fps, FootPlantOptions options)
{
var heights = new Vector3[ankle.Length];
for (var i = 0; i < ankle.Length; i++) heights[i] = up * Vector3.Dot(ankle[i], up);
var candidates = FootPlant.DetectPlants(heights, up, ground, fps, options);
var contacts = new List<FrameRange>();
var previousEnd = 0;
foreach (var candidate in candidates)
{
var lifted = false;
for (var i = previousEnd; i < candidate.Start; i++)
lifted |= Vector3.Dot(ankle[i], up) - ground >= options.HeightThresholdCm * 1.5f;
if (lifted) contacts.Add(candidate);
previousEnd = candidate.End;
}
return contacts;
}
private static void AddFoot(
List<AnimEventEntry> events, List<FrameRange> plants, string attachment, string foot)
{
foreach (var plant in plants)
{
if (plant.Start == 0)
continue; // already planted at clip start: no touchdown inside the clip
events.Add(new AnimEventEntry
{
EventClass = FootstepEventClass,
Frame = plant.Start,
Attachment = attachment,
Foot = foot,
Volume = FootstepVolume,
});
}
}
}