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.
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.
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:
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.
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.
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); } }
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ă.
// 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); }
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;