Smooth Skinning

Smooth skinning is often a point of confusion since it involves multiple bones and weight, but it's not really that complicated. Conceptually, to smooth skin a character, you have to skin the character several different times, and average the results together.

To implement smooth skinning we need to add multiple bones to each vertex. Typically, four bones is the limit for how many joints a vertex can be skinned to. There are two reasons for this. First, we can encode that data into a convenient ivec4. Second, after about 4 bones, the influence of additonal bones becomes unnoticeable.

Each vertex belongs to 4 bones, the model is skinned 4 times and the result is averaged together. Almost... The four bones won't have equal influence over the vertex. Consider a vertex in the fore-arm for example. The upper arm bone might affect the vertex a bit, but nowhere near as much as the lowe arm bone.

To deal with this, we want to also encode how much influence each bone has over a vertex. This data typically comes from the artist that prepared the model, 3D content creation applications allow artists to paint vertex weights on a model.

Updating the vertex structure to support smooth skinning looks like this:

struct Vertex {
    vec3 position;
    vec3 normal;
    vec2 uv;
    ivec4 joints;
    vec4 weights;
}

Let's update the SkinMesh function to do smooth skinning:

void SkinMesh(Mesh& target, const Mesh& source, const Pose& bindPose, const Pose& animPose) {
    for (int i = 0; i < source.vertices.size(); ++i) {
        vec4 position = vec4(soure.vertices[i].position, 1.0f);
        vec4 normal = vec4(soure.vertices[i].normal, 0.0f);
        ivec4 joints = source.vertices[i].joints;
        vec4 weights = source.vertices[i].weights;

        if (joint >= 0) {
            vec4 pos = vec3(0, 0, 0, 0);  // Accumulator
            vec4 norm = vec3(0, 0, 0, 0); // Accumulator

            // For each bone that can influence a vertex
            for (int j = 0; j < 4; ++j) {
                int joint = joints.v[j];
                float weight = weights.v[j];

                mat4 invBindPose = toMat4(bindPose.GetGlobalTransform(joint));
                invBindPose = inverse(invBindPose);
                mat4 animatedPose = toMat4(animPose.GetGlobalTransform(joint));

                pos += (animatedPose * invBindPose) * position * weight;
                norm += (animatedPose * invBindPose) * normal * weight;
            }

            position = pos;
            normal = norm;
        }

        target.vertices[i].position = vec3(position);
        target.vertices[i].normal = normalized(vec3(normal));
    }
}

Not all vertices have to have four bones influencing them. If a vertex only has one, two or three bones influencing it, the joints vector still contains data for all four elements. The weights vector will contain a weight of 0 for any joint that does not influence the vertex, which cancels out that joints effect.

The above code is easy to understand, but typically it's not how you would implement a skin function. Matrices can be combined linearly. Normally, you would combine all four of the bone matrices into one Skin matrix, then multiply the vertex position and normal by that skin matrix. The following code demonstrates this:

void SkinMesh(Mesh& target, const Mesh& source, const Pose& bindPose, const Pose& animPose) {
    for (int i = 0; i < source.vertices.size(); ++i) {
        vec4 position = vec4(soure.vertices[i].position, 1.0f);
        vec4 normal = vec4(soure.vertices[i].normal, 0.0f);
        ivec4 joints = source.vertices[i].joints;
        vec4 weights = source.vertices[i].weights;

        if (joint >= 0) {
            mat4 m0 = toMat4(animPose.GetGlobalTransform(joints.v[0])) * inverse(toMat4(bindPose.GetGlobalTransform(joints.v[0])));
            mat4 m1 = toMat4(animPose.GetGlobalTransform(joints.v[1])) * inverse(toMat4(bindPose.GetGlobalTransform(joints.v[1])));
            mat4 m2 = toMat4(animPose.GetGlobalTransform(joints.v[2])) * inverse(toMat4(bindPose.GetGlobalTransform(joints.v[2])));
            mat4 m3 = toMat4(animPose.GetGlobalTransform(joints.v[3])) * inverse(toMat4(bindPose.GetGlobalTransform(joints.v[3])));

            mat4 skin = m0 * weights.v[0] + m1 * weights.v[1] + m2 * weights.v[2] + m3 * weights.v[3];

            // Move vertex into skin space
            position = skin * position;
            normal = skin * normal;
        }

        target.vertices[i].position = vec3(position);
        target.vertices[i].normal = normalized(vec3(normal));
    }
}