Inverse Kinematics en Foot Locking

Als je met twee animatieprogrammeurs spreekt, zullen ze waarschijnlijk twee volledig verschillende antwoorden geven over de beste manier om foot-sliding op te lossen. Ongetwijfeld is dit onderwerp meer een kunst dan een wetenschap, wat verklaart waarom het niet altijd gemakkelijk is om bronnen te vinden en waarom het in de academische wereld minder aandacht krijgt.

Ik wil daarom een selectie delen van "recepten" voor het oplossen van foot-sliding. Hoewel ik er zeker van ben dat dit niet de bewezen beste oplossingen zijn (er zijn waarschijnlijk veel games die het beter doen), hebben ze mij door de jaren heen goed gediend en vormen ze een goed startpunt voor iedereen die zich in dit onderwerp verdiept.

Om het overzichtelijk te houden, is het onderwerp opgedeeld in een aantal delen:

  1. Mijn methode voor het oplossen van een beenketen om de teen op een gewenste locatie te plaatsen.
  2. Mijn oplossing voor het vastzetten (locking) van de positie van de teen tijdens contactmomenten tijdens runtime met behulp van inertialization.
  3. Een techniek voor het automatisch annoteren van contactmomenten in animatiedata.
  4. Een offline methode voor het verwijderen van foot sliding voor situaties waarin je toegang hebt tot de volledige animatie en extra rekenkracht over hebt.
  5. Filosofische overwegingen over het geheel.

---

Het oplossen van een beenketen

Overzicht

De probleemstelling is als volgt: we hebben een keten van gewrichten voor het been van een personage, geposeerd in een specifieke configuratie. We willen de lokale rotaties zodanig aanpassen dat de bestaande pose zoveel mogelijk behouden blijft, maar waarbij de teen eindigt op een gewenste locatie.

Om dit te bereiken voeren we de volgende stappen uit:

  1. Bereken de doelpositie van de hiel op basis van de doelpositie van de teen.
  2. Los het IK-probleem met twee gewrichten op om de hiel op zijn doelpositie te plaatsen.
  3. Roteer het hielgewricht om de teen naar het doel van de teen te richten.
  4. Roteer optioneel het uiteinde van de teen om eventuele botsingen met de grond op te lossen.

1. De doelpositie van de hiel bepalen

Het berekenen van de gewenste hielpositie is eenvoudig: we berekenen simpelweg de vector van de teen naar de hiel in de bestaande input-pose en voegen dit toe aan het doel van de teen.

*targetHeel = Vector3Add(*targetToe, Vector3Subtract(
    globalTransforms[heelBoneIndex].translation,
    globalTransforms[toeBoneIndex].translation));

2. Oplossen voor de doelpositie van de hiel

Zodra we de hieldoelpositie hebben, kunnen we de lokale rotaties van de heup- en kniegewrichten bepalen, zodat de beenketen correct wordt gepositioneerd. Hiervoor gebruik ik een licht gewijzigde versie van de two bone inverse kinematics code.

static inline Quaternion QuaternionExp(Vector3 v)
{
    float halfangle = sqrtf(v.x*v.x + v.y*v.y + v.z*v.z);
    if (halfangle < 1e-4f)
    {
        return QuaternionNormalize((Quaternion){ v.x, v.y, v.z, 1.0f });
    }
    else
    {
        float c = cosf(halfangle);
        float s = sinf(halfangle) / halfangle;
        return (Quaternion){ s * v.x, s * v.y, s * v.z, c };
    }
}

static inline Quaternion QuaternionFromScaledAngleAxis(Vector3 v)
{
    return QuaternionExp(Vector3Scale(v, 0.5f));
}

static inline void TwoBoneInverseKinematics(
    Quaternion *localHip,
    Quaternion *localKnee,
    Transform globalPelvis,
    Transform globalHip,
    Transform globalKnee,
    Transform globalHeel,
    Vector3 targetHeel,
    Vector3 sideVector,
    float maxExtension,
    float softening)
{
    // Softly clamp the target based on the distance given by maxExtension
    Vector3 targetClamp = targetHeel;
    float targetLength = Vector3Distance(targetHeel, globalHip.translation);
    if (targetLength > maxExtension - softening)
    {
        // Smoothly clamp when it gets within softening distance of the maxExtension
        float saturation = 1.0f - expf(
            -Max(targetLength - maxExtension + softening, 0.0f) / softening);
        targetClamp = Vector3Add(
            globalHip.translation,
            Vector3Scale(Vector3Subtract(targetHeel, globalHip.translation),
            (maxExtension - softening + softening * saturation) / targetLength));
    }

    // Compute the rotation axis based on vector perpendicular to the plane
    // rotation which is closed to the provided knee side vector
    Vector3 axisDwn = Vector3Normalize(
        Vector3Subtract(globalHeel.translation, globalHip.translation));
    Vector3 axisFwd = Vector3Normalize(Vector3CrossProduct(axisDwn, sideVector));
    Vector3 axisRot = Vector3Normalize(Vector3CrossProduct(axisDwn, axisFwd));
    Vector3 a = globalHip.translation;
    Vector3 b = globalKnee.translation;
    Vector3 c = globalHeel.translation;
    Vector3 t = targetClamp;

    // Compute the change in rotation angle required using the cosine rule
    float lab = Vector3Distance(b, a);
    float lcb = Vector3Distance(b, c);
    float lat = Vector3Distance(t, a);
    float lca = Vector3Distance(a, c);
    float acab0 = acosf(Clamp(Vector3DotProduct(
        Vector3Scale(Vector3Subtract(c, a), 1.0f / lca),
        Vector3Scale(Vector3Subtract(b, a), 1.0f / lab)), -1.0f, +1.0f));
    float babc0 = acosf(Clamp(Vector3DotProduct(
        Vector3Scale(Vector3Subtract(a, b), 1.0f / lab),
        Vector3Scale(Vector3Subtract(c, b), 1.0f / lcb)), -1.0f, +1.0f));
    float acab1 = acosf(Clamp(
        (lab * lab + lat * lat - lcb * lcb) / (2.0 * lab * lat), -1.0f, +1.0f));
    float babc1 = acosf(Clamp(
        (lab * lab + lcb * lcb - lat * lat) / (2.0 * lab * lcb), -1.0f, +1.0f));

    // Compute the three world-space rotations needed to solve the two-bone ik problem
    Quaternion r0 = QuaternionFromScaledAngleAxis(Vector3Scale(axisRot, acab1 - acab0));
    Quaternion r1 = QuaternionFromScaledAngleAxis(Vector3Scale(axisRot, babc1 - babc0));
    Quaternion r2 = QuaternionNormalize(QuaternionBetween(
        Vector3Subtract(globalHeel.translation, globalHip.translation),
        Vector3Subtract(targetClamp, globalHip.translation)));

    // Update the local space rotations for the hip and the knee
    *localHip = QuaternionMultiply(QuaternionMultiply(QuaternionMultiply(
        QuaternionInvert(globalPelvis.rotation), r2), r0), globalHip.rotation);
    *localKnee = QuaternionMultiply(QuaternionMultiply(
        QuaternionInvert(globalHip.rotation), r1), globalKnee.rotation);
}

Er zijn hier twee belangrijke toevoegingen. Ten eerste laat ik de gebruiker een maximale verlengingslengte (maxExtension) opgeven, waartegen ik het doel zachtjes begrens (soft clamp) op basis van de softening afstand. Dit betekent dat het ledemaat de opgegeven maxExtension slechts exponentieel nadert, wat helpt om hyper-extensie te voorkomen:

Vector3 targetClamp = targetHeel;
float targetLength = Vector3Distance(targetHeel, globalHip.translation);
if (targetLength > maxExtension - softening)
{
    float saturation = 1.0f - expf(
        -Max(targetLength - maxExtension + softening, 0.0f) / softening);
    targetClamp = Vector3Add(
        globalHip.translation,
        Vector3Scale(Vector3Subtract(targetHeel, globalHip.translation),
        (maxExtension - softening + softening * saturation) / targetLength));
}

Ten tweede gebruik ik de side vector van het kniegewricht om een stabiele as te vinden om omheen te roteren. Dit voorkomt de noodzaak voor een pole-vector.

Vector3 axisDwn = Vector3Normalize(
    Vector3Subtract(globalHeel.translation, globalHip.translation));
Vector3 axisFwd = Vector3Normalize(Vector3CrossProduct(axisDwn, sideVector));
Vector3 axisRot = Vector3Normalize(Vector3CrossProduct(axisDwn, axisFwd));

Uitgaande van twee buffers, localTransforms en globalTransforms, gevuld met de lokale en globale transformaties van de botten, kun je de functie als volgt aanroepen:

// Solve Two-Bone Inverse Kinematics to place heel
Vector3 sideVector = Vector3RotateByQuaternion(
    kneeSideVector,
    globalTransforms[kneeBoneIndex].rotation);
float maxExtension = Vector3Distance(
    globalTransforms[hipBoneIndex].translation,
    globalTransforms[heelBoneIndex].translation);
Quaternion modifiedHip, modifiedKnee;
TwoBoneInverseKinematics(
    &modifiedHip,
    &modifiedKnee,
    globalTransforms[pelvisBoneIndex],
    globalTransforms[hipBoneIndex],
    globalTransforms[kneeBoneIndex],
    globalTransforms[heelBoneIndex],
    *targetHeel,
    sideVector,
    maxExtension,
    softening);
localTransforms[hipBoneIndex].rotation = modifiedHip;
localTransforms[kneeBoneIndex].rotation = modifiedKnee;

Voor het personage Geno is kneeSideVector simpelweg (Vector3){ 1.0f, 0.0f, 0.0f } en een typische waarde voor softening is 0.005f (meters). Zodra dit is voltooid, staat de hiel op het doel, maar door de lokale rotatie die we hebben toegepast, is het teengewricht niet meer gericht op zijn doel.

3. Oplossen voor het doel van de teen

Om het doel van de teen op te lossen, kunnen we een eenvoudige look-at uitvoeren met QuaternionBetween:

static inline Quaternion QuaternionBetween(Vector3 p, Vector3 q)
{
    Vector3 c = Vector3CrossProduct(p, q);
    Quaternion o = {
        c.x,
        c.y,
        c.z,
        sqrtf(Vector3DotProduct(p, p) * Vector3DotProduct(q, q)) +
        Vector3DotProduct(p, q),
    };
    return QuaternionLength(o) < 1e-8f ?
        QuaternionFromAxisAngle((Vector3){ 1.0f, 0.0f, 0.0f }, PI) :
        QuaternionNormalize(o);
}

static inline Quaternion BoneOrientTowards(
    Transform boneParentTransform,
    Transform boneTransform,
    Transform boneChildTransform,
    Vector3 target)
{
    Quaternion desiredRotation = QuaternionMultiply(QuaternionNormalize(
        QuaternionBetween(
            Vector3Subtract(boneChildTransform.translation, boneTransform.translation),
            Vector3Subtract(target, boneTransform.translation))),
        boneTransform.rotation);
    return QuaternionMultiply(
        QuaternionInvert(boneParentTransform.rotation), desiredRotation);
}

Dit kan als volgt worden gebruikt:

ForwardKinematics(globalTransforms, localTransforms, model);
localTransforms[heelBoneIndex].rotation = BoneOrientTowards(
    globalTransforms[kneeBoneIndex],
    globalTransforms[heelBoneIndex],
    globalTransforms[toeBoneIndex],
    *targetToe);

4. Omgaan met botsingen met het grondvlak

Een mooie toevoeging is om de doelposities te laten botsen met het grondvlak. Eerst moeten we de minimale hoogte van de hiel en de teen in de bind pose bepalen:

float heelMinHeight = globalTransforms[leftHeelBoneIndex].translation.y;
float toeMinHeight = globalTransforms[leftToeBoneIndex].translation.y;
float toeEndMinHeight = globalTransforms[leftToeEndBoneIndex].translation.y;

Vervolgens begrenzen we de doelposities tot hun respectievelijke hoogtes tijdens het oplossen van het been:

targetToe->y = Max(targetToe->y, toeMinHeight);
targetHeel->y = Max(targetHeel->y, heelMinHeight);

Bij het doen hiervan willen we mogelijk ook de oriëntatie van het uiteinde van de teen aanpassen, omdat de rotatie moet veranderen als we de botthoogte begrenzen om te voorkomen dat de teen door de vloer gaat.

ForwardKinematics(globalTransforms, localTransforms, model);
*targetToeEnd = globalTransforms[toeEndBoneIndex].translation;
if (enableHeightClamp)
{
    targetToeEnd->y = Max(targetToeEnd->y, toeEndMinHeight);
}
localTransforms[toeBoneIndex].rotation = BoneOrientTowards(
    globalTransforms[heelBoneIndex],
    globalTransforms[toeBoneIndex],
    globalTransforms[toeEndBoneIndex],
    *targetToeEnd);

Samenvatting: De complete leg chain solve

Wanneer we alles samenvoegen, krijgen we de volgende functie:

static inline void SolveLegChain(
    Model model,
    Transform* localTransforms,
    Transform* globalTransforms,
    Vector3 target,
    Vector3 *targetHeel,
    Vector3 *targetToe,
    Vector3 *targetToeEnd,
    int pelvisBoneIndex,
    int hipBoneIndex,
    int kneeBoneIndex,
    int heelBoneIndex,
    int toeBoneIndex,
    int toeEndBoneIndex,
    bool enableHeightClamp,
    bool enableHeelLookAt,
    bool enableToeLookAt,
    float heelMinHeight,
    float toeMinHeight,
    float toeEndMinHeight,
    float softening,
    Vector3 kneeSideVector)
{
    *targetToe = target;
    if (enableHeightClamp)
    {
        targetToe->y = Max(targetToe->y, toeMinHeight);
    }

    // Find the Heel Target Location
    *targetHeel = Vector3Add(*targetToe, Vector3Subtract(
        globalTransforms[heelBoneIndex].translation,
        globalTransforms[toeBoneIndex].translation));
    if (enableHeightClamp)
    {
        targetHeel->y = Max(targetHeel->y, heelMinHeight);
    }

    // Solve Two-Bone Inverse Kinematics to place heel
    Vector3 sideVector = Vector3RotateByQuaternion(
        kneeSideVector,
        globalTransforms[kneeBoneIndex].rotation);
    float maxExtension = Vector3Distance(
        globalTransforms[hipBoneIndex].translation,
        globalTransforms[heelBoneIndex].translation);
    Quaternion modifiedHip, modifiedKnee;
    TwoBoneInverseKinematics(
        &modifiedHip,
        &modifiedKnee,
        globalTransforms[pelvisBoneIndex],
        globalTransforms[hipBoneIndex],
        globalTransforms[kneeBoneIndex],
        globalTransforms[heelBoneIndex],
        *targetHeel,
        sideVector,
        maxExtension,
        softening);
    localTransforms[hipBoneIndex].rotation = modifiedHip;
    localTransforms[kneeBoneIndex].rotation = modifiedKnee;

    // Orient Toe towards Target
    if (enableHeelLookAt)
    {
        ForwardKinematics(globalTransforms, localTransforms, model);
        localTransforms[heelBoneIndex].rotation = BoneOrientTowards(
            globalTransforms[kneeBoneIndex],
            globalTransforms[heelBoneIndex],
            globalTransforms[toeBoneIndex],
            *targetToe);
    }

    // Orient Toe-End
    if (enableToeLookAt)
    {
        ForwardKinematics(globalTransforms, localTransforms, model);
        *targetToeEnd = globalTransforms[toeEndBoneIndex].translation;
        if (enableHeightClamp)
        {
            targetToeEnd->y = Max(targetToeEnd->y, toeEndMinHeight);
        }
        localTransforms[toeBoneIndex].rotation = BoneOrientTowards(
            globalTransforms[heelBoneIndex],
            globalTransforms[toeBoneIndex],
            globalTransforms[toeEndBoneIndex],
            *targetToeEnd);
    }

    // Recompute final global transforms
    ForwardKinematics(globalTransforms, localTransforms, model);
}

Je zult merken dat ik hier wat lui ben en telkens de volledige set globale transformaties opnieuw bereken met de ForwardKinematics functie; in de realiteit hoef je alleen de transformaties te herberekenen voor de botten die je in de volgende fase gebruikt. Ook ga ik ervan uit dat het grondvlak op nul ligt, maar bij dynamisch terrein heb je een raycast of iets soortgelijks nodig om de werkelijke hoogte van de grond onder de voet te bepalen.

Uiteindelijk geeft deze functie ons een soort "black box" waarbij we een bestaande pose van het personage en een nieuw teendoel invoeren, en altijd een redelijk resultaat terugkrijgen. Dit vereenvoudigt het probleem van foot locking aanzienlijk, omdat we nu alleen maar hoeven te zorgen dat ons teendoel niet glijdt.

---

Foot Locking

In het vorige deel hebben we geleerd hoe we een beenketen kunnen oplossen met IK om de teen op een doelpositie te plaatsen. Nu kijken we naar het vastzetten (locking) van het teendoel tijdens contactmomenten.

De werking is simpel: wanneer er geen contact actief is, volgen we de teenpositie in de bronanimatie. Wanneer er wel contact is, volgen we een statische positie op de vloer (de locatie van de teen op het moment dat het contact begon). We gaan tussen deze twee bronnen over met behulp van inertialization.

Voor elk contactpunt moeten we de volgende variabelen bijhouden:

typedef struct FootLockingState
{
    Vector3 position;           // Huidige positie
    Vector3 velocity;           // Huidige snelheid
    Vector3 inputPosition;      // Positie van de bron
    Vector3 inputVelocity;      // Snelheid van de bron
    Vector3 offsetPosition;     // Inertialization offset
    Vector3 offsetVelocity;     // Inertialization offset snelheid
    float time;                 // Tijd sinds de overgang
    Vector3 contact;            // Contactlocatie
    bool locked;                // Of het contact momenteel vastzit
} FootLockingState;

Vervolgens kunnen we per frame een functie schrijven die deze status en de teenlocatie in de input-pose neemt en het teendoel bijwerkt:

void UpdateFootLockingState(
    FootLockingState* state,
    Vector3 inputPosition,
    bool inputContact,
    float contactHeight,
    float deltaTime,
    float unlockDistance,
    float lockDistance,
    float blendTime)
{
    // Update Input State Position and Velocity via finite difference
    state->inputVelocity = Vector3Scale(
        Vector3Subtract(inputPosition, state->inputPosition),
        1.0f / Max(deltaTime, 1e-8f));
    state->inputPosition = inputPosition;

    // Update Cubic Inertialization
    InertializeCubicUpdate(
        &state->position,
        &state->velocity,
        &state->time,
        state->locked ? state->contact : state->inputPosition,
        state->locked ? Vector3Zero() : state->inputVelocity,
        state->offsetPosition,
        state->offsetVelocity,
        deltaTime,
        blendTime);

    // Check the distance of the input location from the locked state
    float inputDistance = Vector3Distance(state->position, state->inputPosition);
    if (!state->locked && inputContact && inputDistance < lockDistance)
    {
        // Lock if the input wants it and the current state is within locking distance
        state->locked = true;
        state->contact = state->inputPosition;
        state->contact.y = contactHeight;
        InertializeCubicTransition(
            &state->offsetPosition,
            &state->offsetVelocity,
            &state->time,
            state->inputPosition,
            state->inputVelocity,
            state->contact,
            Vector3Zero(),
            blendTime);
    }
    else if (state->locked && (!inputContact || inputDistance > unlockDistance))
    {
        // Unlock if the input wants to unlock or the distance is too far
        state->locked = false;
        InertializeCubicTransition(
            &state->offsetPosition,
            &state->offsetVelocity,
            &state->time,
            state->contact,
            Vector3Zero(),
            state->inputPosition,
            state->inputVelocity,
            blendTime);
    }
}

De functies InertializeCubicUpdate en InertializeCubicTransition zijn gedefinieerd als volgt:

void InertializeCubicUpdate(
    Vector3* position,
    Vector3* velocity,
    float* time,
    Vector3 inputPosition,
    Vector3 inputVelocity,
    Vector3 offsetPosition,
    Vector3 offsetVelocity,
    float deltaTime,
    float blendTime)
{
    float t = Clamp((*time + deltaTime) / Max(blendTime, 1e-8f), 0.0f, 1.0f);
    float w0 = 2.0f * t * t * t - 3.0f * t * t + 1.0f;
    float w1 = (t * t * t - 2.0f * t * t + t) * blendTime;
    float w2 = (6.0f * t * t - 6.0f * t) / Max(blendTime, 1e-8f);
    float w3 = 3.0f * t * t - 4.0f * t + 1.0f;
    *position = Vector3Add(inputPosition, Vector3Add(
        Vector3Scale(offsetPosition, w0), Vector3Scale(offsetVelocity, w1)));
    *velocity = Vector3Add(inputVelocity, Vector3Add(
        Vector3Scale(offsetPosition, w2), Vector3Scale(offsetVelocity, w3)));
    *time = *time + deltaTime;
}

void InertializeCubicTransition(
    Vector3* offsetPosition,
    Vector3* offsetVelocity,
    float* time,
    Vector3 sourcePosition,
    Vector3 sourceVelocity,
    Vector3 destinationPosition,
    Vector3 destinationVelocity,
    float blendTime)
{
    float t = Clamp(*time / Max(blendTime, 1e-8f), 0.0f, 1.0f);
    float w0 = 2.0f * t * t * t - 3.0f * t * t + 1.0f;
    float w1 = (t * t * t - 2.0f * t * t + t) * blendTime;
    float w2 = (6.0f * t * t - 6.0f * t) / Max(blendTime, 1e-8f);
    float w3 = 3.0f * t * t - 4.0f * t + 1.0f;
    *offsetPosition = Vector3Subtract(Vector3Add(sourcePosition,
        Vector3Add(Vector3Scale(*offsetPosition, w0),
        Vector3Scale(*offsetVelocity, w1))), destinationPosition);
    *offsetVelocity = Vector3Subtract(Vector3Add(sourceVelocity,
        Vector3Add(Vector3Scale(*offsetPosition, w2),
        Vector3Scale(*offsetVelocity, w3))), destinationVelocity);
    *time = 0.0f;
}

De logica lijkt complex, maar is vrij simpel. Als de input-animatie aangeeft dat de voet vastzit (en de afstand tussen de huidige output en de nieuwe input klein genoeg is), registreren we de contactlocatie en gaan we over naar het volgen hiervan. Als de input-animatie aangeeft dat de voet niet vastzit (of de afstand te groot is), schakelen we terug naar het volgen van de inputlocatie.

Nu kunnen we dit doel koppelen aan de solver uit het vorige deel:

UpdateFootLockingState(
    &leftLockState,
    globalTransforms[leftToeBoneIndex].translation,
    testContacts.leftContacts[animationFrame] > contactThreshold,
    toeMinHeight,
    deltaTime,
    unlockDistance,
    lockDistance,
    lockBlendTime);
leftTarget = leftLockState.position;
if (enableInverseKinematics)
{
    SolveLegChain(
        genoModel,
        modifyTransforms,
        globalTransforms,
        leftTarget,
        &leftTargetHeel,
        &leftTargetToe,
        &leftTargetToeEnd,
        pelvisBoneIndex,
        leftHipBoneIndex,
        leftKneeBoneIndex,
        leftHeelBoneIndex,
        leftToeBoneIndex,
        leftToeEndBoneIndex,
        enableHeightClamp,
        enableHeelLookAt,
        enableToeLookAt,
        heelMinHeight,
        toeMinHeight,
        toeEndMinHeight,
        softening,
        (Vector3){ 1.0f, 0.0f, 0.0f });
}

---

Contacttijden

Om de bovenstaande code te gebruiken, moeten we weten wanneer de teen in de input-animatie contact heeft met de vloer. Meestal voegen we dit toe als metadata aan de animatiedata. Hoewel handmatige labeling de gouden standaard is, kunnen we met eenvoudige heuristieken ongeveer 90% van de weg komen.

Het idee is om te kijken naar de globale snelheid (velocity magnitude) van de teengewrichten in de bronanimatie. Wanneer de snelheid laag is, is de voet waarschijnlijk in contact met de grond. Dit signaal is relatief eenvoudig te thresholden om binaire contactlabels te krijgen.

Snelheid is veel nuttiger dan teenhoogte, omdat mensen hun voeten tijdens locomotie vaak nauwelijks optillen en de hoogte zelfs tijdens contact licht kan variëren. Teenhoogte kan echter nog steeds dienen als sanity check om te voorkomen dat contacten worden gelabeld wanneer de voet stationair is maar in de lucht wordt gehouden.

In kwalitatieve animatiedata is een snelheidsdrempel tussen 0,1 m/s en 0,5 m/s en een hoogte-drempel van 0,1 m vaak redelijk (afhankelijk van de positionering van de teen op het skelet).

Ik pas daarna een paar post-processen toe op het gefilterde signaal:

  1. Majority vote filter: Een filter dat over een kort venster van frames glijdt en een meerderheidsstem gebruikt om de output voor het centrale frame te bepalen. Dit voorkomt activaties van slechts één frame. Een breedte van 5 frames bij 60Hz is een goed begin.
  2. Gaussiaanse smoothing: Dit zet het binaire signaal om in een continu signaal, waardoor we de activaties tijdens runtime kunnen thresholden met een buffer voor gevoeligheid.

Deze heuristiek is niet waterdicht, vooral bij renanimaties waar voetdeformatie groot is en contacttijden kort. Hier helpt het enorm om animatiedata op 60Hz te houden. Als je up-samplt vanaf 30Hz, zijn snelheden berekend via kubieke interpolatie over het algemeen gemakkelijker te thresholden dan die via lineaire interpolatie.

---

Offline Foot Locking

Wanneer we toegang hebben tot de volledige animatieclip en extra rekenkracht hebben, is er een alternatieve aanpak mogelijk die beter presteert dan de runtime-methode met inertialization.

Ik heb ontdekt dat betere resultaten worden behaald als we het foot-sliding probleem formuleren als een set beperkingen (constraints) en deze oplossen met een methode die lijkt op Position-Based Dynamics (PBD).

Het idee is dit: we berekenen de posities van het bekken en de tenen voor elk frame en behandelen deze als een set verbonden deeltjes. We proberen de relatieve positie van deze deeltjes tussen frames, en binnen elk frame, vergelijkbaar te houden met de input-animatie via zachte, veerachtige verbindingen. Vervolgens voegen we hardere verbindingen toe die afdwingen dat er nul afstand is tussen frames voor teen-deeltjes die in contact zijn. Dit "bundelt" effectief alle teen-deeltjes tijdens contactmomenten en verdeelt het effect van deze aanpassing over de rest van de animatie.

We dwingen deze constraints af door herhaaldelijk over de volledige animatie te lussen en de deeltjes telkens een beetje aan te passen.

Eerst berekenen we de locaties van de linkerteen, rechterteen en het bekken:

Vector3* pelvisLocations = RL_CALLOC(animation.frameCount, sizeof(Vector3));
Vector3* leftToeLocations = RL_CALLOC(animation.frameCount, sizeof(Vector3));
Vector3* rightToeLocations = RL_CALLOC(animation.frameCount, sizeof(Vector3));
for (int i = 0; i < animation.frameCount; i++)
{
    pelvisLocations[i] = animation.framePoses[i][pelvisBoneIndex].translation;
    leftToeLocations[i] = animation.framePoses[i][leftToeBoneIndex].translation;
    rightToeLocations[i] = animation.framePoses[i][rightToeBoneIndex].translation;
}

Vervolgens itereren we meerdere keren over de animatie:

float softFactor = 0.05f;
float hardFactor = 0.9f;
int iterations = 25000;
for (int iteration = 0; iteration < iterations; iteration++)
{
    for (int i = 0; i < animation.frameCount; i++)
    {
        // TODO: Enforce constraints...
    }
}

De inter-frame constraints worden als volgt geformuleerd:

// Left Leg Inter-Frame Constraints
if (i > 0)
{
    Vector3 restPrevToe = animation.framePoses[i - 1][leftToeBoneIndex].translation;
    Vector3 restCurrToe = animation.framePoses[i - 0][leftToeBoneIndex].translation;
    Vector3 restPrevHip = animation.framePoses[i - 1][pelvisBoneIndex].translation;
    Vector3 restCurrHip = animation.framePoses[i - 0][pelvisBoneIndex].translation;

    Vector3 consPrevToe = leftToeLocations[i - 1];
    Vector3 consCurrToe = leftToeLocations[i - 0];
    Vector3 consPrevHip = pelvisLocations[i - 1];
    Vector3 consCurrHip = pelvisLocations[i - 0];

    if (contacts.leftContacts[i - 1] > contactThreshold &&
        contacts.leftContacts[i - 0] > contactThreshold)
    {
        Vector3 toeTarget = Vector3Lerp(consPrevToe, consCurrToe, 0.5f);
        toeTarget.y = toeMinHeight;
        leftToeLocations[i - 1] = Vector3Lerp(consPrevToe, toeTarget, hardFactor);
        leftToeLocations[i - 0] = Vector3Lerp(consCurrToe, toeTarget, hardFactor);
    }
    else
    {
        Vector3 prevToeTarget = Vector3Add(consCurrToe,
            Vector3Subtract(restPrevToe, restCurrToe));
        Vector3 currToeTarget = Vector3Add(consPrevToe,
            Vector3Subtract(restCurrToe, restPrevToe));
        prevToeTarget.y = Max(prevToeTarget.y, toeMinHeight);
        currToeTarget.y = Max(currToeTarget.y, toeMinHeight);
        leftToeLocations[i - 1] = Vector3Lerp(consPrevToe, prevToeTarget, softFactor);
        leftToeLocations[i - 0] = Vector3Lerp(consCurrToe, currToeTarget, softFactor);
    }
    pelvisLocations[i - 1] = Vector3Lerp(consPrevHip, Vector3Add(
        consCurrHip, Vector3Subtract(restPrevHip, restCurrHip)), softFactor);
    pelvisLocations[i - 0] = Vector3Lerp(consCurrHip, Vector3Add(
        consPrevHip, Vector3Subtract(restCurrHip, restPrevHip)), softFactor);
}

En de in-frame constraints:

// Left Leg In-Frame Constraints
Vector3 restHip = animation.framePoses[i][pelvisBoneIndex].translation;
Vector3 restToe = animation.framePoses[i][leftToeBoneIndex].translation;
float restLength = Vector3Distance(restHip, restToe);

Vector3 currHip = pelvisLocations[i];
Vector3 currToe = leftToeLocations[i];
Vector3 currDirection = Vector3Normalize(Vector3Subtract(currHip, currToe));

pelvisLocations[i] = Vector3Lerp(currHip, Vector3Add(
    currToe, Vector3Scale(currDirection, +restLength)), softFactor);
leftToeLocations[i] = Vector3Lerp(currToe, Vector3Add(
    currHip, Vector3Scale(currDirection, -restLength)), softFactor);

Na het voltooien van de iteraties passen we de nieuwe doelen toe via de leg solver. Door de softFactor en hardFactor aan te passen, kun je de balans bepalen tussen het strikt afdwingen van de foot-sliding beperking en het volgen van de bronanimatie. Omdat deze methode toegang heeft tot de volledige animatie, verdeelt hij de correctie netjes over het geheel, in plaats van alleen te reageren op contactmomenten zoals bij de runtime-methode.

---

Conclusie en filosofie

Tot slot wil ik dieper ingaan op wat foot sliding eigenlijk is en enkele filosofische tips delen.

Waar komt foot sliding vandaan?

Foot sliding wordt effectief veroorzaakt door twee verschillende situaties:

  1. Wanneer de root van het personage beweegt op een manier die niet overeenkomt met de animatiedata van de rest van het personage. In dit geval glijdt het hele personage, maar we merken dit het meest bij de voeten omdat deze het dichtst bij de grond zijn.
  2. Wanneer we lokale gewrichtsrotaties interpoleren, blenden of modificeren, resulteert dit in bewegingen aan het einde van de keten (zoals de voeten) die niet noodzakelijkerwijs natuurlijk of realistisch zijn.

In beide gevallen komt de visuele snelheid van de voeten in het spel niet overeen met de snelheid in de bronanimatie.

Filosofische lessen

1. Foot sliding gaat over snelheid Een veelgemaakte fout is proberen contacten te labelen op basis van voethoogte. Men denkt aan foot sliding als een schending van natuurkunde en wrijving. Maar het kernpunt is dat we willen dat de snelheid van de voet tijdens runtime overeenkomt met de snelheid in de brongegevens. De reden dat we dit alleen tijdens contactmomenten doen, is een praktische maatregel; als we de snelheid constant zouden beperken, zouden we de rest van de animatie niet kunnen volgen.

2. De teen is wat vastgezet moet worden Bij locomotie is 90% van de tijd het gebied rond de teen in contact met de vloer. Zelfs bij atletische bewegingen landen mensen vaker op de voorvoet dan we verwachten. De hiel is zelden een stabiel ankerpunt. Wanneer we het IK-probleem oplossen, moeten we de hiel dus zoveel mogelijk vrijheid geven en ons bijna volledig concentreren op het beperken van de teen.

3. Inverse Kinematics is een modificatie, geen vervanging Veel mensen zien IK als een proces dat de pose van drie gewrichten volledig vervangt op basis van procedurele regels (zoals een pole-vector). Maar IK kan ook worden toegepast als een minimale modificatie van een bestaande animatie, waarbij we gewrichten naar een doel verplaatsen maar de rotaties zoveel mogelijk behouden. Dit bewaart de nuances van de originele animatie.

4. Vermijd de "dinosaurus" Een veelvoorkomend probleem is het naar beneden trekken van de heupen om de benen meer ruimte te geven en hyper-extensie te voorkomen. Dit creëert echter een T-Rex-effect (te sterk gebogen knieën). In echte animatiedata is het been vaak extreem dicht bij hyper-extensie.

Mijn laatste advies is dit: een beetje sliding is veel beter dan het breken van de bronanimatie om de beweging mechanisch correct te maken. Stop met denken in termen van wrijving, en begin te denken in termen van het behouden van de input-bewegingssnelheid.