Static helper that generates AE_FOOTSTEP animation events from solved per-frame skeleton poses. It computes ankle world positions, estimates ground height, detects contact ranges per foot, filters out contacts that were not preceded by a lift, and emits AnimEventEntry objects ordered by frame and foot.
#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,
});
}
}
}