Code/TextToAnimation/Processing/FootstepEvents.cs

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.

Native Interop
#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,
            });
        }
    }
}