Dual Quaternion Skinning with scale

DQS with scale applied on the second to last joint. Left, globally propagates until the last bone, right, scale localized to each joint.

Here I'm merely paraphrasing Kavan's DQS article using my own notations and words. You should read my tutorial on Dual Quaternion Skinning (DQS) first. Once successfully implemented you can easily extend it to support scale given the following instructions. I will use the notations I introduced in my skeletal animation cheat sheet.

Unlike matrices, by definition Dual Quaternions are not intended to represent scale transformations. A dual Quaternion only represents a translation and a rotation. To represent all three transformations you must divide the skinning algorithm into two stages, first scale, then rotation and translation.

First you must apply Linear Blending Skinning (LBS) but only considering the scale component \( S_j \) of the joints. This will have the effect of bulging your character in the rest pose. In the second stage you will apply DQS with the remaining rigid transformation \( R_j \) and rotate the inflated skin.

There is absolutely no difficulties separating your skinning algorithm into those two stages. The difficult part is to compute the global scale matrix \( S_j \) and computing the rigid matrix \(R_j\) containing rotation and translation. \( S_j \) is used instead of the LBS matrix \( T_j \) and blended by the unaltered LBS routine. \(R_j\) is converted to Dual Quaternion and blended through the unmodified DQS routine.

Now there are two main ways to infer the scale matrices, one that produce a "global propagation" of the scale (if a joint is scaled then it will affect all descendants of that joint). Or a "local application" where scale only affects the concerning joint. Below I present the two approaches with my notations and code. Again, those two approaches are described in Kavan's paper.

Global propagation

You can allow the scale to propagate throughout the skeleton, any joint scale will also scale its children (top left figure). The advantage of this version is that you can directly use \(T_j \).

By applying a polar decomposition on \(T_j \) it will separate the transformation into two matrices, a rigid component and a scale component \( T_j = R_j S_j \). I provide here polar decomposition routines for 3x3 matrices (you could also use Eigen).

std::vector<Mat4x4> scale(nb_bones);
std::vector<Dual_quat> rigid(nb_bones);
// Kynematic
for(int i = 0; i < nb_bones; ++i)
{
    Mat4x4 tr = skinning_transformations[i]; // T_j
    Polar_decomposition decomp( tr.get_mat3() );
    scale[i] = Mat4x4(decomp.matrix3x3_S());
    rigid[i] = Dual_quat(Mat4x4(decomp.matrix3x3_R(), tr.get_translation()));
}
// Deformation
for(int i = 0; i < nb_vertices; ++i)
{
    Point3 in_vert = in_vertex_buffer[i]; // p_i
    Vec3 in_normal = in_normal_buffer[i]; // n_i
    Point3 out_vert;
    Vec3 out_normal;
    {
        // p'i =  (sum over every influencing bone j)(w_ij T_j) p_i
        // n'i = ((sum over every influencing bone j)(w_ij T_j))^(-1)^(T) n_i
        out_vert = linear_blending(bone_weights[i], scale, in_vert, in_normal, out_normal);
        // Apply DQS on top of the inflated mesh:
        Dual_quat dq_blend = dual_quaternion_blending( bone_weights[i], rigid);
        out_normal = dq_blend.rotate( out_normal );
        out_vert   = dq_blend.transform(out_vert);
    }
    out_vertex_buffer[i] = out_vert; // p'i
    out_normal_buffer[i] = out_normal.normalized(); // n'i 
}

Local scale

Localizing the scale only on specific joint of the skeleton is a little more tricky. The computation of \( R_j \) and \( S_j \) is more involved and you need access to the user local transformations \( Ul_j = Ur_j Us_j \) at each joint. We assume access to \(Ur_j\) and \( Us_j \) but they can be obtain through polar decomposition.

$$
\begin{equation*}
\begin{split}
S_j & = Ws_j (B_j)^{-1} \\
Ws_j & = Ls_{\text{root}} \ \cdots \ Ls_{p(j)} Ls_j Us_j \\
Ls_j & = \text{mat4}(rot(L_j), Us_{p(j)} \ \ trans(L_j) )\\
\end{split}
\end{equation*}
$$

$$\begin{equation*}
\begin{split}
R_j & = Wr_j (Ws_j Us_j^{-1})^{-1} \\
Wr_j & = Ls_{\text{root}} Ur_{\text{root}} \ \cdots \ Ls_{p(j)} Ur_{p(j)} Ls_j Ur_j
\end{split}
\end{equation*} $$

Then you only have to adapt the kinematic part of your code. (Comments of the code below are using Kavan's notations to designated matrices)

/// node_geom_skinning.cpp
/// DQS with SCALE, using Kavan solution
/// SCALE is local. It does not propagate to children
size_t nb_bones = skel->get_transfos().size();
std::vector<Transfo> bind_inverse = skel->bind_inverse(); // (A')^-1
std::vector<Transfo> bind = skel->bind(); // (A')
std::vector<Transfo> joint_anim_scale_compens(nb_bones, Transfo::identity()); // F'
std::vector<Transfo> skinning_rigid_scale_compens(nb_bones, Transfo::identity()); // C''
std::vector<Transfo> user_local = skel->_kinec->get_user_locals();

joint_anim_frame_scale_compens(joint_anim_scale_compens, 
                               skinning_rigid_scale_compens, 
                               user_local, 
                               bind, 
                               skel->root(), 
                               *skel);

std::vector<Transfo> scale(nb_bones); // C'
std::vector<Dual_quat> normalized(nb_bones); // C''
for(int i = 0; i < nb_bones; ++i)
{
    scale[i] = joint_anim_scale_compens[i] * bind_inverse[i]; // C' = F' (A')^-1
    normalized[i] = Dual_quat(skinning_rigid_scale_compens[i]);
}

for(int i = start; i < end; ++i)
{
    Vec3 in_vert   = get_input_buff_mesh()->_vertex_buffer[i];
    Vec3 in_normal = get_input_buff_mesh()->_normal_buffer[i];
    Vec3 out_vert;
    Vec3 out_normal = in_normal.normalized();
    {
        out_vert = linear_blending(_weights[i], scale, in_vert, in_normal, out_normal);

        Dual_quat dq_blend = anim::dual_quaternion_blending( _weights[i], normalized);
        out_normal = dq_blend.rotate( out_normal );
        out_vert   = Vec3(dq_blend.transform(Point3(out_vert)));

    }
    get_output_buff_mesh()->_vertex_buffer[i] = out_vert;
    get_output_buff_mesh()->_normal_buffer[i] = out_normal.normalized();
}

void joint_anim_frame_scale_compens(
        std::vector<Transfo>& joint_frame_scale,
        std::vector<Transfo>& rigid,
        const std::vector<Transfo>& user_lcl,
        const std::vector<Transfo>& bind,
        int joint_id,
        const anim::Skeleton& skel,
        const Transfo& parent_scale = Transfo::identity(),
        const Transfo& parent_rigid = Transfo::identity())
{
    int pid = skel.parent(joint_id) > -1  ? skel.parent(joint_id) : joint_id;
    Transfo bind_lcl = skel.bind_local(joint_id);

#if 1
    Transfo bind_lcl_scale_compens = bind_lcl;
    Polar_decomposition<false> decomp_parent( user_lcl[pid].get_mat3() );
    bind_lcl_scale_compens.set_translation( 
        Transfo(decomp_parent.matrix_S()) * Point3( bind_lcl_scale_compens.get_translation()) 
        );

    // bind_lcl_scale_compens == R'

    Transfo world_pos = parent_scale * bind_lcl_scale_compens; // A''

    Polar_decomposition<false> decomp( user_lcl[joint_id].get_mat3() );
    
    // F' = ... R' S
    joint_frame_scale[joint_id] = world_pos * Transfo(decomp.matrix_S()); 
    
#else
    // This works if user_lcl is a pure scale.
    // In this case it's equivalent to the above.
    bind_lcl.set_translation( user_lcl[pid] * bind_lcl.get_translation() );

    Transfo world_pos = parent * bind_lcl;

    joint_frame[joint_id] = world_pos * user_lcl[joint_id];
#endif


    // F'' = ...  R' T
    Transfo frame_rigid = parent_rigid * 
                          bind_lcl_scale_compens * 
                          Transfo(decomp.matrix_R(), user_lcl[joint_id].get_translation() ); 
                          
    rigid[joint_id] = frame_rigid * world_pos.fast_inverse();

    for(unsigned i = 0; i < skel.get_sons( joint_id ).size(); i++)
        joint_anim_frame_scale_compens(joint_frame_scale, 
                                       rigid, 
                                       user_lcl, 
                                       bind, 
                                       skel.get_sons( joint_id )[i], 
                                       skel, 
                                       world_pos, 
                                       frame_rigid);
}

Mixed

On can also decides whether scale should propagate or not given a specific joint. This approach is discussed in Enhanced Dual Quaternion Skinning for Production Use.

References

No comments

(optional field, I won't disclose or spam but it's necessary to notify you if I respond to your comment)
All html tags except <b> and <i> will be removed from your comment. You can make links by just typing the url or mail-address.
Anti-spam question: