5.6 Skeleton Animation

So far, we have learned how to animate rigid objects by updating their model-view matrices. In this tutorial, we will explore how to create skeleton animations for animating characters like animals and human figures. Unlike rigid objects, these models deform during animation.

Launch Playground - 5_06_skeleton_animation

Fundamentally, any animation can be achieved by directly updating vertex positions. However, this approach requires substantial storage for vertex data, making it impractical for real-time use. Skeleton animation, inspired by natural systems, defines a simplified structure—a skeleton—consisting of rigid parts called bones connected by joints that can be rotated. The actual vertices of the 3D model are attached to these bones.

Bones are organized hierarchically, with the spine as the root, limbs as branches, and fingers as twigs. Consequently, updating a root bone affects all attached child bones, making animation intuitive.

To determine the actual rotation and translation of a bone, we must recursively apply transformations from the root to the end bone.

Each bone influences the vertices based on its influence factor, where a value of zero means no influence. Once the transformations for each bone are decided, we update the vertices by applying these bone transformations scaled by their respective influences.

Now, let’s discuss implementing skeleton animation. The first step is to create an animated asset. I followed a simple tutorial in Blender for this purpose. Once the animation is complete, we need to export it to a file. Note that not all 3D file formats support animation; for instance, the OBJ file format does not. In this example, we will use the DAE file format. As in previous tutorials, our focus is not on implementing a file parser; instead, we will use an existing format parser and convert the necessary data into JSON for loading in JavaScript. This time, the preprocessing will be done in C++, as our DAE file parser is written in C++.

#include <iostream>
#include <assimp/Importer.hpp>
#include <assimp/scene.h>
#include <assimp/postprocess.h>
#include <fstream>
#include <unordered_map>

struct Bone
{
    aiBone *bone;
    aiNodeAnim *ani;
};

void printHierarchy(std::ofstream &outputFile, aiNode *node, int indentation,
                    std::unordered_map<std::string, Bone> &bones, int &boneId, bool isRoot)
{
    std::cout << std::string(indentation, '-') << node->mName.C_Str() << std::endl;

    const std::string boneName = std::string(node->mName.C_Str());

    if (bones.find(boneName) != bones.end())
    {
        std::cout << "bone = = " << boneName << std::endl;
        outputFile << "{\"id\":" << (boneId++) << ",\"nodeTransform\":[" << std::endl;

        outputFile << node->mTransformation[0][0] << "," << node->mTransformation[0][1] << "," << node->mTransformation[0][2] << "," << node->mTransformation[0][3] << "," << node->mTransformation[1][0] << "," << node->mTransformation[1][1] << "," << node->mTransformation[1][2] << "," << node->mTransformation[1][3] << "," << node->mTransformation[2][0] << "," << node->mTransformation[2][1] << "," << node->mTransformation[2][2] << "," << node->mTransformation[2][3] << "," << node->mTransformation[3][0] << "," << node->mTransformation[3][1] << "," << node->mTransformation[3][2] << "," << node->mTransformation[3][3] << std::endl;

        outputFile << "],\"name\":\"" << node->mName.C_Str() << "\",\"children\":[" << std::endl;

        for (int i = 0; i < node->mNumChildren; ++i)
        {
            printHierarchy(outputFile, node->mChildren[i], indentation + 1, bones, boneId, false);

            if (i < node->mNumChildren - 1)
            {
                outputFile << "," << std::endl;
            }
        }

        outputFile << "]" << std::endl;

        aiBone *b = bones[boneName].bone;
        outputFile << ", \"offsetMatrix\":[";
        for (int i = 0; i < 4; ++i)
        {
            auto w = b->mOffsetMatrix[i];
            outputFile << w[0] << "," << w[1] << "," << w[2] << "," << w[3];
            if (i < 3)
            {
                outputFile << ",";
            }
        }
        outputFile << "],\n \"weights\":[" << std::endl;

        for (int i = 0; i < b->mNumWeights; ++i)
        {
            outputFile << "{\"id\":" << b->mWeights[i].mVertexId << ",\"w\":" << b->mWeights[i].mWeight << "}";
            if (i < b->mNumWeights - 1)
            {
                outputFile << "," << std::endl;
            }
        }
        outputFile << "]";

        aiNodeAnim *ani = bones[boneName].ani;

        if (ani)
        {

            outputFile << ",\"ani\":{" << std::endl;

            if (ani->mNumPositionKeys > 0)
            {
                outputFile << "\"pos\":[";

                for (int e = 0; e < ani->mNumPositionKeys; ++e)
                {
                    auto pk = ani->mPositionKeys[e];
                    outputFile << "{\"time\":" << pk.mTime << ",\"pos\":[" << pk.mValue[0] << "," << pk.mValue[1] << "," << pk.mValue[2] << "]}" << std::endl;
                    if (e < ani->mNumPositionKeys - 1)
                    {
                        outputFile << ",";
                    }
                }

                outputFile << "]" << std::endl;
            }

            if (ani->mNumRotationKeys > 0)
            {
                outputFile << ",\"rot\":[";

                for (int e = 0; e < ani->mNumRotationKeys; ++e)
                {
                    auto rk = ani->mRotationKeys[e];
                    outputFile << "{\"time\":" << rk.mTime << ",\"q\":[" << rk.mValue.w << "," << rk.mValue.x << "," << rk.mValue.y << "," << rk.mValue.z << "]}" << std::endl;
                    if (e < ani->mNumRotationKeys - 1)
                    {
                        outputFile << ",";
                    }
                }

                outputFile << "]" << std::endl;
            }

            if (ani->mNumScalingKeys > 0)
            {
                outputFile << ",\"scal\":[";

                for (int e = 0; e < ani->mNumScalingKeys; ++e)
                {
                    auto sk = ani->mScalingKeys[e];
                    outputFile << "{\"time\":" << sk.mTime << ",\"pos\":[" << sk.mValue[0] << "," << sk.mValue[1] << "," << sk.mValue[2] << "]}" << std::endl;
                    if (e < ani->mNumScalingKeys - 1)
                    {
                        outputFile << ",";
                    }
                }

                outputFile << "]" << std::endl;
            }

            outputFile << "}" << std::endl;
        }
        outputFile << "}";
    }
    else
    {
        if (isRoot)
        {
            outputFile << "{\"name\":\"" << node->mName.C_Str() << "\",\"nodeTransform\":[" << 1 << "," << 0 << "," << 0 << "," << 0 << "," << 0 << "," << 1 << "," << 0 << "," << 0 << "," << 0 << "," << 0 << "," << 1 << "," << 0 << "," << 0 << "," << 0 << "," << 0 << "," << 1 << std::endl;
        }
        else
        {
            outputFile << "{\"name\":\"" << node->mName.C_Str() << "\",\"nodeTransform\":[" << node->mTransformation[0][0] << "," << node->mTransformation[0][1] << "," << node->mTransformation[0][2] << "," << node->mTransformation[0][3] << "," << node->mTransformation[1][0] << "," << node->mTransformation[1][1] << "," << node->mTransformation[1][2] << "," << node->mTransformation[1][3] << "," << node->mTransformation[2][0] << "," << node->mTransformation[2][1] << "," << node->mTransformation[2][2] << "," << node->mTransformation[2][3] << "," << node->mTransformation[3][0] << "," << node->mTransformation[3][1] << "," << node->mTransformation[3][2] << "," << node->mTransformation[3][3] << std::endl;
        }
        outputFile << "],\"children\":[" << std::endl;
        for (int i = 0; i < node->mNumChildren; ++i)
        {
            printHierarchy(outputFile, node->mChildren[i], indentation + 1, bones, boneId, false);

            if (i < node->mNumChildren - 1)
            {
                outputFile << "," << std::endl;
            }
        }

        outputFile << "]}";
    }
}

int main()
{
    std::cout << "test" << std::endl;
    const char *path = "../../../data/cuberun.dae";
    Assimp::Importer importer;
    const aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);
    if (!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode)
    {
        std::cout << "ERROR::ASSIMP::" << importer.GetErrorString() << std::endl;
        return 1;
    }

    std::cout << "mesh count " << scene->mNumMeshes << std::endl;

    const aiMesh *mesh = scene->mMeshes[0];

    std::cout << "mesh uv channel " << mesh->GetNumUVChannels() << std::endl;

    // std::cout << "children count " << scene->mRootNode->mChildren[0]->mNumChildren << std::endl;

    std::ofstream outputFile;
    outputFile.open("cuberun.json");
    outputFile << "{\"vert\":[";

    for (unsigned int i = 0; i < mesh->mNumVertices; i++)
    {
        outputFile << mesh->mVertices[i][0] << ", " << mesh->mVertices[i][1] << ", " << mesh->mVertices[i][2] << ", ";
        outputFile << mesh->mNormals[i][0] << ", " << mesh->mNormals[i][1] << ", " << mesh->mNormals[i][2];
        if (i != mesh->mNumVertices - 1)
        {
            outputFile << ", " << std::endl;
        }
    }
    outputFile << "],\"indices\":[\n";

    for (unsigned int i = 0; i < mesh->mNumFaces; ++i)
    {
        aiFace face = mesh->mFaces[i];

        // std::cout << "face ";
        for (unsigned int f = 0; f < face.mNumIndices; ++f)
        {
            outputFile << face.mIndices[f];
            if (i != mesh->mNumFaces - 1 || f != face.mNumIndices - 1)
            {
                outputFile << ", ";
            }
        }
        outputFile << std::endl;
    }
    outputFile << "],\"skeleton\":[\n";

    std::unordered_map<std::string, Bone> bones;

    if (mesh->HasBones())
    {
        for (int i = 0; i < mesh->mNumBones; ++i)
        {
            bones[std::string(mesh->mBones[i]->mName.C_Str())] = {mesh->mBones[i], nullptr};
            // std::cout << "insert bone " << mesh->mBones[i]->mName.C_Str() << std::endl;
        }
        std::cout << "animation size " << scene->mNumAnimations << std::endl;

        aiAnimation *ani = scene->mAnimations[0];

        std::cout << "channel size" << ani->mNumChannels << std::endl;

        for (int i = 0; i < ani->mNumChannels; ++i)
        {
            aiNodeAnim *a = ani->mChannels[i];
            std::string boneName = std::string(a->mNodeName.C_Str());
            if (bones.find(boneName) != bones.end())
            {
                bones[boneName].ani = a;
            }
        }
    }
    int boneId = 0;
    printHierarchy(outputFile, scene->mRootNode, 0, bones, boneId, true);

    outputFile << "]}";

    outputFile.close();

    /*
        std::cout << "has tex " << mesh->HasTextureCoords(0) << std::endl;

        std::cout << "has bone " << mesh->HasBones() << std::endl;

        std::cout << "bones " << mesh->mNumBones << std::endl;
*/

    return 0;
}

The library we use to parse the DAE file is called Assimp. This versatile tool can handle various common 3D formats. Assimp organizes 3D objects into a scene structure. For our demo file, which contains only one mesh, we retrieve mesh zero. We then need to extract the geometry data, which includes vertices, vertex normals, and triangle meshes, similar to the process used for OBJ files.

Our main focus is on extracting the bones and animations. The scene structure contains a list of bones, each with a unique name, and an animation list. Since we have only one animation, there is just one animation object. This animation object has multiple channels, each associated with a bone. A channel includes a node name that corresponds to the associated bone. Using this information, we can map bones to their respective animations.

The scene structure organizes objects hierarchically, a common approach in computer graphics. For instance, in a 3D model of a car, the wheels are children of the car body. In a scene with multiple car models, the scene itself is the root node, with each car as a child node. Parts and accessories, such as wheels and doors, are children of the car nodes. This hierarchy allows transformations applied to a parent node to automatically affect all its children. For example, moving the car also moves its wheels and doors. Thus, when calculating the transformation of a leaf node, we must account for all transformations accumulated from the scene root.

In our demo scene, although there is only one object, the bones are also organized hierarchically. Consequently, moving the shoulder will affect the forearm and fingers.

In the second part of data extraction, we need to dump this hierarchical structure. We start with the scene root, which, although not a 3D object itself, represents the scene. Since the scene root has no transformation, we assign it an identity matrix. For the remaining nodes, we determine if a node is a bone or part of the 3D mesh. If it is a mesh, we save its transformation. If it is a bone, we save its offset matrix, weights, and animations.

The offset matrix is specific to bones and provides additional positional information. The weights are tuples for each vertex, where the first element is the vertex ID and the second element is the influence number.

For animations, we extract a sequence of transformations. The sequence length corresponds to the number of keyframes, each with a timestamp and three types of transformations: translation, rotation, and scaling. Translation and scaling are represented as vectors, while rotation is represented as quaternions.

let boneWeights = new Float32Array(this.objBody.vert.length * 16 / 3);

function assignBoneWeightsToVerticesHelper(bone) {

    if (bone.weights) {
        for (let i = 0; i < bone.weights.length; ++i) {
            const { id, w } = bone.weights[i];
            boneWeights[id * 16 + bone.id] = w;
        }
    }

    if (bone.children) {
        if (bone.children.length > 0) {
            for (let i = 0; i < bone.children.length; ++i) {
                assignBoneWeightsToVerticesHelper(bone.children[i]);
            }
        }
    }
}

for (let i = 0; i < this.objBody.skeleton.length; ++i) {
    assignBoneWeightsToVerticesHelper(this.objBody.skeleton[i]);
}

The first step in processing the file is to create a flattened vertex bone weight array. Since we have 16 bones, including the root scene, each vertex will be associated with 16 float values. Although there are actually 13 bones, we round up to the nearest multiple of four for alignment reasons, which will be explained later. Given that bones are organized in a hierarchical tree structure, a helper function is used to recursively visit all bone nodes.

updateAnimation(time) {
    let boneTransforms = new Float32Array(16 * 16);

    function interpolatedV(time, V, interpolate) {

        time = time % V[V.length - 1].time;
        /*  while (time > V[V.length - 1].time) {
              time -= V[V.length - 1].time;
          }*/

        let firstIndex = -1;

        for (; firstIndex < V.length - 1; ++firstIndex) {
           // const i = (firstIndex + V.length) % V.length;
           // console.log(i,firstIndex)
            if (time < V[firstIndex + 1].time) {
                break;
            }
        }

        const secondIndex = firstIndex + 1;

        let startTime = 0;
        let endTime = V[secondIndex].time;

        if (firstIndex == -1) {
            firstIndex = V.length - 1;
        }
        else {
            startTime = V[firstIndex].time;
        }

        const factor = (time - startTime) / (endTime - startTime);

        return interpolate(V[firstIndex], V[secondIndex], factor);
    }


    function deriveBoneTransformHelper(bone, parentTransform) {
        if (bone.id !== undefined) {
            const offsetMatrix = glMatrix.mat4.fromValues(
                bone.offsetMatrix[0],
                bone.offsetMatrix[4],
                bone.offsetMatrix[8],
                bone.offsetMatrix[12],
                bone.offsetMatrix[1],
                bone.offsetMatrix[5],
                bone.offsetMatrix[9],
                bone.offsetMatrix[13],
                bone.offsetMatrix[2],
                bone.offsetMatrix[6],
                bone.offsetMatrix[10],
                bone.offsetMatrix[14],
                bone.offsetMatrix[3],
                bone.offsetMatrix[7],
                bone.offsetMatrix[11],
                bone.offsetMatrix[15]);
            if (bone.ani) {

                const interpolatedPos = interpolatedV(time, bone.ani.pos, (pos1, pos2, factor) => {
                    return glMatrix.vec3.lerp(glMatrix.vec3.create(), glMatrix.vec3.fromValues(pos1.pos[0], pos1.pos[1], pos1.pos[2]),
                        glMatrix.vec3.fromValues(pos2.pos[0], pos2.pos[1], pos2.pos[2]), factor);
                });
                const translationMatrix = glMatrix.mat4.fromTranslation(glMatrix.mat4.create(),
                    interpolatedPos);

                const interpolatedQuat = interpolatedV(time, bone.ani.rot, (quat1, quat2, factor) => {
                    return glMatrix.quat.lerp(glMatrix.quat.create(),
                        glMatrix.quat.fromValues(quat1.q[1], quat1.q[2], quat1.q[3], quat1.q[0]),
                        glMatrix.quat.fromValues(quat2.q[1], quat2.q[2], quat2.q[3], quat2.q[0]),
                        factor
                    );
                });

                const rotationMatrix = glMatrix.mat4.fromQuat(glMatrix.mat4.create(), interpolatedQuat);

                const interpolatedScale = interpolatedV(time, bone.ani.scal, (scal1, scal2, factor) => {
                    return glMatrix.vec3.lerp(glMatrix.vec3.create(), glMatrix.vec3.fromValues(scal1.pos[0], scal1.pos[1], scal1.pos[2]),
                        glMatrix.vec3.fromValues(scal2.pos[0], scal2.pos[1], scal2.pos[2]), factor);
                });

                const scalingMatrix = glMatrix.mat4.fromScaling(glMatrix.mat4.create(), interpolatedScale);

                const rotation_x_scale = glMatrix.mat4.multiply(glMatrix.mat4.create(),
                    rotationMatrix, scalingMatrix);

                const locationTransformation = glMatrix.mat4.multiply(glMatrix.mat4.create(),
                    translationMatrix, rotation_x_scale);

                const globalTransformation = glMatrix.mat4.multiply(glMatrix.mat4.create(),
                    parentTransform, locationTransformation);

                const finalBoneTransformation = glMatrix.mat4.multiply(glMatrix.mat4.create(),
                    globalTransformation, offsetMatrix);

                boneTransforms.set(finalBoneTransformation, bone.id * 16);
                /*
                                        console.log("bone transform ", bone.name, finalBoneTransformation);
                                        console.log("node trans", locationTransformation);
                                        console.log("parent", parentTransform);*/

                if (bone.children.length > 0) {
                    for (let i = 0; i < bone.children.length; ++i) {
                        deriveBoneTransformHelper(bone.children[i], globalTransformation);
                    }
                }
            }
            else {
                const nodeTransform = glMatrix.mat4.fromValues(bone.nodeTransform[0],
                    bone.nodeTransform[4],
                    bone.nodeTransform[8],
                    bone.nodeTransform[12],
                    bone.nodeTransform[1],
                    bone.nodeTransform[5],
                    bone.nodeTransform[9],
                    bone.nodeTransform[13],
                    bone.nodeTransform[2],
                    bone.nodeTransform[6],
                    bone.nodeTransform[10],
                    bone.nodeTransform[14],
                    bone.nodeTransform[3],
                    bone.nodeTransform[7],
                    bone.nodeTransform[11],
                    bone.nodeTransform[15]);

                const bt = glMatrix.mat4.multiply(glMatrix.mat4.create(), parentTransform, nodeTransform);
                const bt2 = glMatrix.mat4.multiply(glMatrix.mat4.create(), bt, offsetMatrix);
                boneTransforms.set(bt2, bone.id * 16);
                /* console.log("= bone transform ", bone.name, bt2);
                 console.log("= bone nodeTransform", nodeTransform);
                 console.log("global ", bt);*/

                if (bone.children.length > 0) {
                    for (let i = 0; i < bone.children.length; ++i) {
                        deriveBoneTransformHelper(bone.children[i], bt);
                    }
                }
            }
        }

        else {

            const nodeTransform = glMatrix.mat4.fromValues(bone.nodeTransform[0],
                bone.nodeTransform[4],
                bone.nodeTransform[8],
                bone.nodeTransform[12],
                bone.nodeTransform[1],
                bone.nodeTransform[5],
                bone.nodeTransform[9],
                bone.nodeTransform[13],
                bone.nodeTransform[2],
                bone.nodeTransform[6],
                bone.nodeTransform[10],
                bone.nodeTransform[14],
                bone.nodeTransform[3],
                bone.nodeTransform[7],
                bone.nodeTransform[11],
                bone.nodeTransform[15]);

            const globalTransformation = glMatrix.mat4.multiply(glMatrix.mat4.create(),
                parentTransform, nodeTransform);

            // console.log("* bone transform ", bone.name, globalTransformation);

            if (bone.children.length > 0) {
                for (let i = 0; i < bone.children.length; ++i) {
                    deriveBoneTransformHelper(bone.children[i], globalTransformation);
                }
            }
        }
    }

    for (let i = 0; i < this.objBody.skeleton.length; ++i) {
        deriveBoneTransformHelper(this.objBody.skeleton[i], glMatrix.mat4.identity(glMatrix.mat4.create()));
    }

    //console.log("bone transformation", boneTransforms);
    return boneTransforms;

}

Next, we examine a helper function designed to return the current bone transformations given a specific timestamp. This function retrieves all necessary transformations for each bone, including local translation, rotation, and scaling, as well as accumulated transformations from its parent and the offset matrix. Since the requested timestamp may not correspond to a keyframe, interpolation is performed to compute the appropriate transformation. The function then recursively processes all child bones. The result is a flattened transformation array for all bones, which is passed as a uniform to the shader.

@group(0) @binding(0)
var<uniform> modelView: mat4x4<f32>;
@group(0) @binding(1)
var<uniform> projection: mat4x4<f32>;
@group(0) @binding(2)
var<uniform> normalMatrix: mat4x4<f32>;
@group(0) @binding(3) 
var<uniform> lightDirection: vec3<f32>;
@group(0) @binding(4)
var<uniform> viewDirection: vec3<f32>;

@group(0) @binding(5)
var<uniform> ambientColor:vec4<f32>;// = vec4<f32>(0.15, 0.10, 0.10, 1.0);
@group(0) @binding(6)
var<uniform> diffuseColor:vec4<f32>;// = vec4<f32>(0.55, 0.55, 0.55, 1.0);
@group(0) @binding(7)
var<uniform> specularColor:vec4<f32>;// = vec4<f32>(1.0, 1.0, 1.0, 1.0);

@group(0) @binding(8)
var<uniform> shininess:f32;// = 20.0;

@group(1) @binding(0)
var<uniform> boneTransforms: array<mat4x4<f32>, 16>;
    
const diffuseConstant:f32 = 1.0;
const specularConstant:f32 = 1.0;
const ambientConstant: f32 = 1.0;

fn specular(lightDir:vec3<f32>, viewDir:vec3<f32>, normal:vec3<f32>,  specularColor:vec3<f32>, 
     shininess:f32) -> vec3<f32> {
    let reflectDir:vec3<f32> = reflect(-lightDir, normal);
    let specDot:f32 = max(dot(reflectDir, viewDir), 0.0);
    return pow(specDot, shininess) * specularColor;
}

fn diffuse(lightDir:vec3<f32>, normal:vec3<f32>,  diffuseColor:vec3<f32>) -> vec3<f32>{
    return max(dot(lightDir, normal), 0.0) * diffuseColor;
}

struct VertexOutput {
    @builtin(position) clip_position: vec4<f32>,
    @location(0) viewDir: vec3<f32>,
    @location(1) normal: vec3<f32>,
    @location(2) lightDir: vec3<f32>,
    @location(3) wldLoc: vec3<f32>,
    @location(4) lightLoc: vec3<f32>,
    @location(5) inPos: vec3<f32>
};

@vertex
fn vs_main(
    @location(0) inPos: vec3<f32>,
    @location(1) inNormal: vec3<f32>,
    @location(2) boneWeight0: vec4<f32>,
    @location(3) boneWeight1: vec4<f32>,
    @location(4) boneWeight2: vec4<f32>,
    @location(5) boneWeight3: vec4<f32>
) -> VertexOutput {
    var out: VertexOutput;
    var totalTransform:mat4x4<f32> = mat4x4<f32>(0.0,0.0,0.0,0.0,
        0.0,0.0,0.0,0.0,
        0.0,0.0,0.0,0.0,
        0.0,0.0,0.0,0.0);
    
        totalTransform += boneTransforms[0]  * boneWeight0[0];
        totalTransform += boneTransforms[1] * boneWeight0[1];
        totalTransform += boneTransforms[2]* boneWeight0[2];
        totalTransform += boneTransforms[3] * boneWeight0[3];
        totalTransform += boneTransforms[4] * boneWeight1[0];
        totalTransform += boneTransforms[5] * boneWeight1[1];
        totalTransform += boneTransforms[6] * boneWeight1[2];
        totalTransform += boneTransforms[7] * boneWeight1[3];
        totalTransform += boneTransforms[8] * boneWeight2[0];
        totalTransform += boneTransforms[9] * boneWeight2[1];
        totalTransform += boneTransforms[10]* boneWeight2[2];
        totalTransform += boneTransforms[11] * boneWeight2[3];
        totalTransform += boneTransforms[12] * boneWeight3[0];


    out.viewDir = normalize((normalMatrix * vec4<f32>(-viewDirection, 0.0)).xyz);
    out.lightDir = normalize((normalMatrix * vec4<f32>(-lightDirection, 0.0)).xyz);
    out.normal = normalize(normalMatrix * totalTransform * vec4<f32>(inNormal, 0.0)).xyz;  
    var wldLoc:vec4<f32> = modelView *totalTransform *vec4(inPos,1.0);
    out.clip_position = projection * wldLoc;
    out.wldLoc = wldLoc.xyz / wldLoc.w;
    out.inPos = (totalTransform *vec4(inPos,1.0)).xyz;
    var lightLoc:vec4<f32> = modelView * vec4<f32>(lightDirection, 1.0);
    out.lightLoc = lightLoc.xyz / lightLoc.w;

    return out;
}

The shader used here is an adaptation of the shadow map shader, with modifications primarily in the vertex shader. We pass two additional pieces of information: the bone transformations as a uniform containing the current animation frame's bone transformations, and the bone weights as vertex attributes. Since vertex attributes cannot directly handle large data like matrices, we decompose the weights into four vectors, each holding four float values. This necessitates rounding the number of bones to 16 for alignment.

In the vertex shader, we calculate a weighted sum of the bone transformations based on the bone weights. This computed transformation is then applied to the vertex, followed by the model-view matrix and the projection matrix, as previously.

It’s important to note that we assign a dedicated group ID to the bone transformation uniform. This is because the bone transformations are updated frequently, so separating them from other, more stable uniforms helps optimize performance.

this.boneWeightBuffer = createGPUBuffer(device, boneWeights, GPUBufferUsage.VERTEX);
• • •
const boneWeight0AttribDesc = {
    shaderLocation: 2,
    offset: 0,
    format: 'float32x4'
};

const boneWeight1AttribDesc = {
    shaderLocation: 3,
    offset: 4 * 4,
    format: 'float32x4'
};

const boneWeight2AttribDesc = {
    shaderLocation: 4,
    offset: 4 * 8,
    format: 'float32x4'
};

const boneWeight3AttribDesc = {
    shaderLocation: 5,
    offset: 4 * 12,
    format: 'float32x4'
};

const boneWeightBufferLayoutDesc = {
    attributes: [boneWeight0AttribDesc, boneWeight1AttribDesc, boneWeight2AttribDesc, boneWeight3AttribDesc],
    arrayStride: 4 * 16,
    stepMode: 'vertex'
};
• • •
const pipelineDesc = {
    layout,
    vertex: {
        module: shaderModule,
        entryPoint: 'vs_main',
        buffers: [positionBufferLayoutDesc,
            normalBufferLayoutDesc,
            boneWeightBufferLayoutDesc]
    },
    fragment: {
        module: shaderModule,
        entryPoint: 'fs_main',
        targets: [colorState]
    },
    primitive: {
        topology: 'triangle-list',
        frontFace: 'ccw',
        cullMode: 'none'
    },
    depthStencil: {
        depthWriteEnabled: true,
        depthCompare: 'less',
        format: 'depth32float'
    }
};

Next, let’s look at how to set up the bone weight buffer. We have already created the flattened bone weight array; now, we use this array to populate four vertex attributes that store the bone weights.

if (!startTime) {
    startTime = timestamp;
}
const elapsed = timestamp - startTime;
• • •
const boneTransforms = runCube.updateAnimation(elapsed);
let boneTransformBufferUpdate = createGPUBuffer(device, boneTransforms, GPUBufferUsage.COPY_SRC);
• • •
commandEncoder.copyBufferToBuffer(boneTransformBufferUpdate, 0,
    runCube.boneTransformUniformBuffer, 0, boneTransforms.byteLength);

Finally, let's examine how animation transforms are updated. For each frame, we measure the elapsed time, use this to compute the current bone transformations, and then load these transformations into the uniform buffer. This ensures that the animation remains fluid and up-to-date as the scene progresses.

Rendering Result
Rendering Result

Leave a Comment on Github