Table of Contents

Laboratorul 07

Animația Scheletală

Animația scheletală sau Skinning este un proces ce cuprinde două etape. Prima etapă este făcută de artist, iar a doua de către programator.

Prima etapă se realizează cu ajutorul unei aplicații de modelare și se numește Rigging. Ce se întâmplă în acest pas este faptul că artistul creează un schelet de oase “în interiorul” modelului 3D. Modelul 3D reprezintă pielea obiectului, iar oasele sunt folosite pentru a muta mesh-ul într-un mod care va imita mișcarea din lumea reală. Acest lucru se face asociind fiecărui vertex cel puțin un os. Fiecare os se asociază unui vertex cu o pondere, care va determina cât de mult va influența acel os vertexul atunci când se mișcă. Suma tuturor ponderilor pentru un vertex trebuie sa fie 1. De exemplu, dacă un vertex este situat exact la jumătate între două oase, preferabil fiecare os va avea ponderea 0.5, deoarece este de așteptat ca ambele oase să aibă o influență egală asupra vertexului. Cu toate acestea, dacă vertexul este influențat de un singur os, atunci ponderea osului va fi 1 (osul va avea control total asupra vertexului). Un exemplu de schelet realizat în Blender:

Artistul definește un set de animații (“walk”, “run”, “die” etc.). Fiecare animație conține un set de cadre cheie (key frame-uri). Un cadru cheie contine transformările tuturor oaselor în acel cadru. Aceste cadre trebuie interpolate pentru a crea o tranziție fluentă între ele.

Un model 3D poate avea multiple animații. În cazul acestui laborator folosim un model 3D cu o singură animație.

Structura de oase folosită pentru animația scheletală este arborescentă. Fiecare os are un părinte cu excepția osului rădăcină (a se vedea cursul). Când un os părinte se mișcă, mișcă automat și toți copiii săi, dar când un os copil se mișcă, nu își mișcă și părintele (degetele se pot mișca fără să se miște mâna, dar mâna nu se poate mișca fără să modifice și degetele). Astfel, când se procesează transformările unui os, trebuie să se aplice asupra osului și transformările tuturor oaselor părinte de la rădăcină până la nodul curent. Transformările oaselor, care sunt asociate arcelor arborelui, sunt explicate la curs.

Atributele vertecșilor

Primul pas în implementarea animației este adăugarea de noi atribute pentru fiecare vertex al obiectului. Pentru fiecare vertex se va adăuga un array de informații despre oase (un id și o pondere). Astfel, structura unui vertex va arăta în modul acesta:

Pentru simplitate, vom considera că niciun vertex nu poate fi influențat de mai mult de 4 oase. Dacă doriți să folosiți modele 3D cu mai mult de 4 oase, va trebui modificată dimensiunea array-ului de oase, dar pentru modelul folosit în acest laborator 4 oase sunt suficiente.

Poziția, normala și coordonatele de textură sunt deja mapate la locațiile 0, 1 și 2. VAO trebuie configurat pentru a mapa și indicii oaselor la locația 3, respectiv, ponderile la locația 4. Acest proces este realizat o singură dată la startup când obiectul se încarcă.

    glBindBuffer(GL_ARRAY_BUFFER, buffers.m_VBO[0]);
    glBufferData(GL_ARRAY_BUFFER, sizeof(positions[0]) * positions.size(), &positions[0], GL_STATIC_DRAW);
    glEnableVertexAttribArray(VERTEX_ATTRIBUTE_LOC::POS);
    glVertexAttribPointer(VERTEX_ATTRIBUTE_LOC::POS, 3, GL_FLOAT, GL_FALSE, 0, 0);

    glBindBuffer(GL_ARRAY_BUFFER, buffers.m_VBO[1]);
    glBufferData(GL_ARRAY_BUFFER, sizeof(normals[0]) * normals.size(), &normals[0], GL_STATIC_DRAW);
    glEnableVertexAttribArray(VERTEX_ATTRIBUTE_LOC::NORMAL);
    glVertexAttribPointer(VERTEX_ATTRIBUTE_LOC::NORMAL, 3, GL_FLOAT, GL_FALSE, 0, 0);

    glBindBuffer(GL_ARRAY_BUFFER, buffers.m_VBO[2]);
    glBufferData(GL_ARRAY_BUFFER, sizeof(text_coords[0]) * text_coords.size(), &text_coords[0], GL_STATIC_DRAW);
    glEnableVertexAttribArray(VERTEX_ATTRIBUTE_LOC::TEX_COORD);
    glVertexAttribPointer(VERTEX_ATTRIBUTE_LOC::TEX_COORD, 2, GL_FLOAT, GL_FALSE, 0, 0);

    glBindBuffer(GL_ARRAY_BUFFER, buffers.m_VBO[3]);
    glBufferData(GL_ARRAY_BUFFER, sizeof(bones[0]) * bones.size(), &bones[0], GL_STATIC_DRAW);
    glEnableVertexAttribArray(VERTEX_ATTRIBUTE_LOC::BONE);
    glVertexAttribIPointer(VERTEX_ATTRIBUTE_LOC::BONE, 4, GL_INT, sizeof(VertexBoneData), (const GLvoid*)0);
    
    glEnableVertexAttribArray(VERTEX_ATTRIBUTE_LOC::WEIGHT);
    glVertexAttribPointer(VERTEX_ATTRIBUTE_LOC::WEIGHT, 4, GL_FLOAT, GL_FALSE, sizeof(VertexBoneData), (const GLvoid*)16);

Atributul id al unui os reprezintă poziția din acest array de transformări ale oaselor (ce transformare trebuie aplicată pe osul respectiv). Atributul pondere va fi folosit pentru a combina transformările mai multor oase într-o singură transformare. Ponderea totală pentru un os trebuie să fie 1.

Implementarea animației pe CPU

Pe lângă id-uri și ponderi (care sunt fixe la nivel de vertex), va fi necesar să se salveze și transformările care trebuie aplicate asupra vertexului într-un anumit moment de timp. Aceste transformări vor fi aplicate pe poziția și normala vertexului înainte de transformarea MVP (putem considera că trecem vertexul din spațiul de coordonate local în “spațiul de coordonate al oaselor”).

Transformările oaselor sunt salvate într-o structură arborescentă. Acestea se vor modifica la fiecare frame. Fiecare transformare la un anumit moment de timp este definită printr-un vector de scalare, un vector de translație și un quaternion de rotație. De asemenea, este necesar și un time stamp (după cât timp după ce a început animația ar trebui să se aplice asupra vertexului translația, rotația și scalarea asociate). Cazul în care time stamp-ul dintre un os va corespunde la fix cu time stamp-ul curent este aproape imposibil, așa că va fi necesară o interpolare între scalări/rotații/translații pentru a obține transformarea corectă pentru momentul curent de timp. Acest proces de interpolare se repetă pentru toți vertecșii obiectului.

Transformarea finală a unui os pentru time stamp-ul curent este calculată ținând cont și de rezultatul transformării osului părinte. Astfel, prin înmulțire, se va aplica rezultatul interpolării osului părinte asupra rezultatului interpolării osului curent. La final, transformările calculate pentru fiecare os sunt aplicate în shader.

O animație are o durată exprimată în ticks și un număr de ticks pe secundă (de exemplu 100 ticks și 25 ticks/secundă reprezintă o animație de 4 secunde), ce sunt folosite pentru a uniformiza animația astfel încât să dureze la fel de mult pe orice dispozitiv indiferent de performanțele sale hardware.

void BoneTransform(Mesh* mesh, float timeInSeconds, std::vector<glm::mat4>& transforms)
{
    glm::mat4 Identity = glm::mat4(1.0f);

    // Compute the duration of the animation
    float ticksPerSecond = mesh->anim[0]->mTicksPerSecond;
    float timeInTicks = timeInSeconds * ticksPerSecond;
    float animationTime = fmod(timeInTicks, mesh->anim[0]->mDuration);

    // Compute the final transformations for each bone at the current time stamp
    // starting from the root node
    ReadNodeHierarchy(mesh, animationTime, mesh->rootNode, Identity, mesh->anim);
}

void ReadNodeHierarchy(Mesh* mesh, float animationTime, const aiNode* pNode, const glm::mat4& parentTransform, aiAnimation** anim)
{
    const aiAnimation* pAnimation = anim[0];
    glm::mat4 NodeTransformation(mesh->ConvertMatrix(pNode->mTransformation));

    // Interpolate the scaling and generate the scaling transformation matrix
    ComputeInterpolatedScaling(Scaling, animationTime, pNodeAnim);

    // Interpolate the rotation and generate the rotation transformation matrix
    ComputeInterpolatedRotation(RotationQ, animationTime, pNodeAnim);
    glm::quat rotation(RotationQ.w, RotationQ.x, RotationQ.y, RotationQ.z);
    glm::mat4 RotationM = glm::toMat4(rotation);

    // Interpolate the translation and generate the translation transformation matrix
    ComputeInterpolatedPosition(Translation, animationTime, pNodeAnim);

    // Combine the above transformations
    NodeTransformation = TranslationM * RotationM * ScalingM;

    // Apply the parent transformation to the current transformation
    glm::mat4 GlobalTransformation = parentTransform * NodeTransformation;

    // Bring the vertices from their local space position into their node space.
    // Multiply the result with the combined transformations of all the node parents plus the current transformation.
    // Bring the result back into local space
    mesh->m_BoneInfo[BoneIndex].finalTransformation = mesh->m_GlobalInverseTransform * GlobalTransformation * mesh->m_BoneInfo[BoneIndex].boneOffset;

    // Compute the transformations of the children of the current node
    for (unsigned int i = 0; i < pNode->mNumChildren; i++) {
        ReadNodeHierarchy(mesh, animationTime, pNode->mChildren[i], GlobalTransformation, anim);
    }
}

Implementarea animației pe GPU

La final, trebuie modificat vertex shader-ul astfel încât să integreze și transformările oaselor. Mai întâi, shader-ul așteaptă ca atribute un array de oase de id-uri și un array de ponderi. Pe urmă, se folosește o variabilă uniformă nouă, în care se vor primi matricile de transformare ale tuturor oaselor la momentul curent de timp. Se va calcula transformarea finală a vertexului drept o combinație între matricile de transformare ale oaselor vertexului și ponderile lor. Această matrice se va aplica asupra poziției (în sistemul local de coordonate) și normalei vertexului pentru a face trecerea în “spațiul de coordonate al oaselor”. La final, se va aplica transformarea MVP obișnuită.

Nu uitați că pentru acest laborator am considerat că fiecare vertex este influențat de maxim 4 oase! Dar un os este influențat și de părintele său. Astfel, pe CPU veți calcula matricile de transformare pentru fiecare os (aplicând interpolări și ținând cont de osul părinte), iar pe GPU veți aplica transformările calculate ale celor 4 oase care influențează vertexul curent

// Input
layout(location = 0) in vec3 v_position;
layout(location = 1) in vec3 v_normal;
layout(location = 2) in vec2 v_texture_coord;
layout(location = 3) in ivec4 BoneIDs;
layout(location = 4) in vec4 Weights;

const int MAX_BONES = 100;

// Uniform properties
uniform mat4 Model;
uniform mat4 View;
uniform mat4 Projection;
uniform mat4 Bones[MAX_BONES];

// Output
layout(location = 0) out vec2 texture_coord;
layout(location = 1) out vec3 normal;

void main()
{

    mat4 BoneTransform = Bones[BoneIDs[0]] * Weights[0];
    BoneTransform += Bones[BoneIDs[1]] * Weights[1];
    BoneTransform += Bones[BoneIDs[2]] * Weights[2];
    BoneTransform += Bones[BoneIDs[3]] * Weights[3];

    texture_coord = v_texture_coord;
    normal = mat3(BoneTransform) * v_normal; 

    gl_Position = Projection * View * Model * BoneTransform * vec4(v_position, 1.0);
}

La ce folosim normala? În acest laborator, calculăm normala însă nu este folosită la nimic. Scopul este să înțelegeți cum recalculăm normalele, pentru a le putea folosi ulterior la alte procese (de exemplu, în implementarea iluminării).

Implementarea interpolării transformărilor

Dorim să implementăm evoluția celor 3 transformări de bază în funcție de timp. Pentru a realiza acest lucru, este nevoie să facem o interpolare liniară a fiecărei transformări.

timeInterpolationFactor = currentTime - initialTime;
interpolatedTransformation = initialTransformation + (finalTransformation - initialTransformation) * timeInterpolationFactor;

Pentru o implementare cât mai exactă a rotațiilor, vom folosi quaternioni (a se vedea cursul). Pentru a realiza interpolarea quaternionilor, biblioteca Assimp ne pune la dispoziție clasa aiQuaternion, care are deja implementată funcția de interpolare.

Cerinte laborator

  1. Calculați transformarea finală pentru time stamp-ul curent. Pentru fiecare os:
    1. Pentru fiecare transformare (translație, rotație, scalare), găsiți cel mai apropiat time stamp mai mic decât time stamp-ul curent (datele sunt sortate crescător în funcție de time stamp).
    2. Interpolați fiecare transformare (translație, rotație, scalare) între două time stamp-uri consecutive pentru a se potrivi time stamp-ului curent.
    3. Aplicați transformarea nodului părinte asupra transformării nodului curent.
  2. Trimiteți către shader array-ul de transformări ale oaselor.
  3. Modificați vertex shader-ul astfel încât să aplicați transformarea oaselor asupra poziției și normalei.
  4. Trimiteți normala calculată în vertex shader către fragment shader. Modificați fragment shader-ul astfel încât să primească normala.