跳转至

本文由 简悦 SimpRead 转码, 原文地址 www.jianshu.com

前言

由于数学公式的渲染 BUG,后台正常显示的公式在前台无法正常渲染,截了一个长图出来(可能会更新后面的文章,但长图无法频繁更新,如有出入希望谅解):

长图

基本理论

每个骨骼关节点(Joint)的位置公式可以写为
  意思是第 i 个节点的位置,要用第 i-1 个点的位置,加上一个向量,这个向量是以初始骨骼方向为初始向量,绕着第 i-1 个点的位置,旋转之前所有顶点旋转的累加和。
  光是这么说不好理解,可以看看这篇文章,里面图解很详细【翻译】正向运动学的数学知识
  知道了大致原理,但实现上还碰到了不少问题,我们继续学下面的理论。

子空间变换到父空间

变换理论

表示子空间到父空间的变换,M 表示为:  其中 U 包含旋转和缩放,T 表示位移,i,j,k 向量是子空间的基向量(坐标轴)在父空间的方向表示。
  例如,有一个父空间,和一个子空间,子空间绕着 Z 轴旋转了**γ**度:

沿方向的单位向量是,沿方向的单位向量是,沿方向的单位向量是,因此矩阵就表示为:

此时如果在子空间轴上有一点 ,我们拓展第四分量为 1 并左乘于矩阵:

可得到新的向量  这就是子空间的在父空间的位置,还有一种右乘矩阵的写法:

注意,左乘与右乘的 Rotation 和 Translation 矩阵都有区别。(这里的左乘与右乘是指 “向量” 左乘和 “向量” 右乘)

左乘矩阵

绕 X 旋转

绕 Y 旋转

绕 Z 旋转

平移

右乘矩阵

绕 X 旋转

绕 Y 旋转

绕 Z 旋转

平移


  注意,矩阵左乘和右乘**不等于**左手坐标系变换矩阵和右手坐标系变换矩阵,将两个坐标系矩阵互相转换,是用:  可以看到转换结果是:矩阵的变为了原来的相反数,而绕 X、绕 Y 变换矩阵的右手左乘矩阵恰好就是左手坐标系右乘矩阵,而左右手坐标系的绕 Z 旋转矩阵都是一样的。
  在这里,左乘与右乘是转置的关系。

连续变换

对于 3D 中的空间来说往往不止一层,比如说骨骼空间的层次:

  • 世界空间
    • 全亲骨骼(模型空间)
      • 下半身
        • 上半身

……
  假如我们知道**上半身骨骼**在**下半身骨骼空间**中的位置,以及所有父空间的相对位置和旋转,怎么推测到上半身骨骼在世界空间中的什么位置?
  答案是:首先求出**上半身**在**模型空间**的位置,然后再推出上半身在**世界空间的位置**。  而矩阵乘法符号结合律,因此括号可以去掉,而前面矩阵的乘法就可以写成:  写成一个矩阵和向量的乘积,矩阵的含义就变为了:直接从父空间到模型空间的转换矩阵。
  举个例子,假设模型绕世界 Z 轴正方向旋转了 90° 并向世界空间 X 轴负方向移动 1 个单位,下半身绕模型空间 Z 轴正方向旋转 270° 并向模型空间 Y 轴正方向移动 1 个单位,上半身的关节点在下半身空间的处:

求上半身关节点在世界空间的位置。

首先世界空间没有父空间,因此它的

M

就是  模型空间:  下半身骨骼空间:  然后将的上半身关节点坐标代入:  当然,也可以用左乘的方式:

Opengl 中的注意事项

opengl 中我们常进行矩阵和向量的变幻:gl_Position = Projection * View * Translate * Rotate * Scale * vec4(pos, 1);,看起来是右乘,实际上,无论是矩阵和矩阵的乘法,还是矩阵和向量的乘法,以及变换矩阵的表示方法,都是左乘,按照从左至右的算法,肯定是错误的,用笔计算时,一定要将公式倒过来写。

骨骼旋转中的空间变换

如果仔细想前面的理论,其实存在着几个问题。
注: 这里提前说明,下面一段偏向于理论,实现上会容易一些!
首先,上例我们默认子骨骼空间都是从父骨骼空间的原点处出发开始变换的;但是实际上,骨骼空间有自己的初始值(移动和旋转)。这可能容易混淆。
  例如,上半身骨骼节点在下半身空间中绕 X 轴正方向旋转 90° 向下半身空间 Y 轴移动一个单位,这是我们所说的骨骼变换,但实际上,上半身节点本身就处在下半身空间的某个位置,也可能骨骼空间有一个初始旋转值,因此变换实际上的过程会复杂化:  其中是子骨骼初始空间到父空间的变换。
  举个例子,空间 Child 在空间 Parent处,基向量方向和 Parent 保持一致,随后 ChildX 旋转了 90°,并向 ParentY 轴移动了一个单位,求 Child 坐标为的空间点在变换后在 Parent 空间的位置。

黑色是 Parent

我们使用右乘公式:  这显然是我们需要的结果。

理所当然的,从子骨骼到世界空间的一系列变换都需要多这一过程。这其实是很麻烦的,骨骼链很长,每一个骨骼都要计算自身基于父骨骼的变换,因此我们可以选择另一种方式。

骨骼空间的初始位置定义是可以由我们决定的,由此,我们

约定,子骨骼空间的初始状态都没有基于父骨骼空间旋转

,如上例,没有旋转能方便很多。

然后换一种思考方式,所有骨骼空间的初始状态都是从父骨骼的完全拷贝,而变换则变成了从原点到结束点的累积变换。

如上例,假如我们先将两个矩阵相乘,得到这个结果:  就像是子骨骼空间初始就和父骨骼空间一致,然后先旋转,再移动了

“子骨骼在父骨骼空间的位置”

“子骨骼在父空间的移动”

的加和,如此来,每一层的计算再次变得简单起来。

这个问题解决了,让我们思考

第二个

问题。

我们渲染管道要的不是骨骼关节点 (Joint) 的位置,而是每个顶点在世界空间的位置,根据关节点在世界空间的坐标变换或变换的加权平均,得到顶点在世界空间所在的位置。

模型文件给出的顶点位置是对象坐标系下的,传入

vertex shader

中的也是对象坐标的顶点。(这里的对象坐标是模型各顶点的初始坐标,可以理解为未经变换的世界坐标)

而我们上述所讲的变换,所需要传入的是顶点在骨骼坐标系下的位置。如果说一个顶点只受一个骨骼影响,我们还可以算出顶点在骨骼空间的相对位置再传入 shader,但很多顶点会受到多个骨骼影响,受到加权平均。

以下是一个 vertex shader 骨骼动画的基本写法:

#version 330
layout(location = 15) in vec3 aPos;//顶点位置
layout(location = 14) in vec3 aNormal;//法向量方向
layout(location = 13) in vec2 aTexCoord;//UV坐标
layout(location = 12) in vec4 boneIndexs;//受到影响骨骼索引1个到4个
layout(location = 11) in vec4 boneWeights;//每个骨骼权重
layout(location = 10) in float weightFormula;//记录受到几个骨骼影响
//给fragment shader
out vec2 TexCoord;
out vec3 FragPos;
out vec3 Normal;
//M包括对模型整体的移动旋转缩放,VP包括视野View矩阵和透视Projection矩阵
uniform mat4 MVP;
uniform mat4 M;
//每个骨骼的空间变换矩阵
#define MAX_BONE 230
uniform mat4 bones[MAX_BONE];

void main(){
    vec4 newPosition = vec4(aPos, 1.0);
    vec4 newNormal = vec4(aNorml, 0.0);//法向量只有旋转和缩放,没有移动

    int index1 = int(boneIndexs.x);//索引取整数
    int index2 = int(boneIndexs.y);
    int index3 = int(boneIndexs.z);
    int index4 = int(boneIndexs.w);

    if(weightFormula == 0){//BDEF1
        newPosition = bones[index1] * newPosition;
        newNormal = bones[index1] * newNormal;
    }else if(weightFormula == 1 || weightFormula == 3){//BDEF2 or SDEF
        newPosition = (bones[index1] * newPosition) * boneWeights.x + (bones[index2] * newPosition) * boneWeights.y;
        newNormal = (mat3(bones[index1])*aNormal) * boneWeights.x + (mat3(bones[index2]) * aNormal) * boneWeights.y;
    }
//....
}

显然提前算出顶点在骨骼空间的位置是不可能的,而传入所有骨骼的信息到 uniform 值中是不合算的。
  于是我们再次用变换矩阵解决,我们约定,骨骼空间初始基向量和父空间保持一致(既没有旋转),这样一系列从祖宗到孙子骨骼空间的初始状态都没有旋转,只有移动,于是想要得到顶点的**骨骼空间坐标**,只需要令**顶点的对象空间坐标**减去**骨骼关节点在对象空间的位置**就好。
  例如,全亲骨在世界(或对象)坐标系原点,右肘关节在世界坐标系减去右肘关节的坐标,即可得到顶点 V 处于右肘关节的的坐标

从这里我们就可以看到方才约定的好处,可以不用去计算父骨骼的旋转。

现在我们将其构造为矩阵:  其含义为,传入一个对象空间的坐标,可将其变为当前骨骼坐标系的坐标,我们称这个矩阵为

初始绑定矩阵

,称这个变换过程为

参考姿势下的骨骼初始逆变换

具体使用方式,是和前面的空间转换结合,以右乘的写法如下:  其中,前面矩阵的乘积,便是我们要传递给 shader 的矩阵

代码样例

提前声明:这个代码是我 MMD Viewer 程序的一部分,等以后完善了,可能放出完整代码,现在肯定是不能跑的。

类型声明

namespace VPD {
    struct Bone {
        std::string name;
        glm::vec3 translate;
        glm::quat quaternion;
    };
    enum class Coor {
        LEFT,
        RIGHT
    };
    class File {
    public:
        std::vector<Bone> bones;
        std::string useModelName;
        static File* from_file(std::string filename, Coor coor = Coor::LEFT, std::string source_encoding = "shift-jis");

        Bone* operator[](std::string name) {
            for (Bone& bone : bones) {
                if (bone.name == name) {
                    return &bone;
                }
            }
            return nullptr;
        }
    private:
        File() {};
    };

VPD 是 MikuMikuDance 的姿势文件,以文本方式存储(既可以直接右键阅读更改,动作数据 VMD 不能),存储格式就是:骨骼名称、移动、旋转(上面的 Bone)。Coor 是坐标系的枚举,因为 MMD 是 DirectX 写的,用的是左手坐标系,而我用的是 Opengl 仿写,用的是右手坐标系。File 是 VPD 文件的抽象。
  from_file 是用来解析文件并返回 File 对象指针,我就不放具体代码了。左右手坐标系转换我说一下,位置可以直接让 Z 轴取反就可以,四元数可以用glm::mat3_cast转换为矩阵,然后用上面提到的理论,左右都乘上 Z,再用glm::quat_cast转换回四元数即可。

namespace Animation{
    struct BNode {
        BNode(PMX::Bone& _bone) : bone(_bone) {};
        int32_t index;
        PMX::Bone& bone;
        BNode* parent;
        std::vector<BNode*> childs;
        glm::mat4 Mconv;
    };

    class BoneManager
    {
    public:
        BoneManager(PMX::File* model);
        ~BoneManager();
        BNode* operator[](std::string name);

        std::vector<BNode*> linearList;
        std::vector<BNode*> roots;

    };
}

然后是骨骼管理,BNode 作为骨骼树的节点,记录骨骼本身、亲骨、子骨,以及最终的变换矩阵。
  骨骼管理,构造方法接受一个 PMX 模型文件对象,PMX 是 MikuMikuDance 的模型文件,存储了模型的各类数据。
  骨骼管理采用双索引方式:线性索引和树形索引。PMX 文件本身采用线性索引,各种关于骨骼的记录都是线性 index,而构造变换矩阵时,我们希望从根节点开始构造,这样省下了递归、重复构造父节点的变换矩阵;注意,PMX 文件可能存在不止一个根节点,因此存储的是每个树的根节点,而骨骼管理存储就可以看做 “森林”。

BoneManager::BoneManager(PMX::File* model) {
        linearList.resize(model->bones.size());
        for (int32_t i = 0; i < linearList.size(); ++i) {//线性初始化
            linearList[i] = new BNode(model->bones[i]);
            BNode& curr_node = *linearList[i];
            curr_node.index = i;
        }

        for (BNode* node : linearList) {
            if (node->bone.parentBoneIndex != -1) {//非根节点
                node->parent = linearList[node->bone.parentBoneIndex];//认个爹
                linearList[node->bone.parentBoneIndex]->childs.push_back(node);//让爹认自己这个儿子
            }
            else {//根节点
                node->parent = nullptr;
                roots.push_back(node);//交给根节点列表
            }
        }
    };
    BoneManager::~BoneManager() {
        for (BNode* node : linearList) {
            delete node;
        }
    }
    BNode* BoneManager::operator[](std::string name) {
        for (BNode* node : linearList) {
            if (node->bone.localName == name) {
                return node;
            }
        }
        return nullptr;
    };
class Pose {
    public:
        Animation::BoneManager boneManager;

        Pose(PMX::File* model, File* file) : boneManager(model){
        //需要一个模型文件和一个VPD文件,直接构造骨骼管理器,因为Pose类处于VPD的名称空间下,因此File前不比加名称空间
            std::stack<Animation::BNode*> traversal;//一个用来深度遍历森林非递归写法的栈
            for (Animation::BNode* root : boneManager.roots) {//遍历森林里的每一颗树
                traversal.push(root);
                do {
                    Animation::BNode* currNode = traversal.top();
                    Bone* bone = (*file)[currNode->bone.localName];//VPD名称空间下的Bone
                    if (bone == nullptr) {//这个骨骼没有在记录中出现
                        if (currNode->parent == nullptr) {//且是根节点
                            currNode->Mconv = glm::translate(glm::mat4(1), currNode->bone.position);//就等于自己在对象空间的位置
                        }
                        else {//不是根节点
                            currNode->Mconv = currNode->parent->Mconv * glm::translate(glm::mat4(1), currNode->bone.position - currNode->parent->bone.position);//亲骨空间变换累积自己空间的变换
                        }
                    }
                    else {// 如果记录存在
                        if (currNode->parent == nullptr) {//且是根节点
                            currNode->Mconv = glm::translate(glm::mat4(1), currNode->bone.position + bone->translate) * glm::mat4_cast(bone->quaternion);
                        }
                        else {
                            currNode->Mconv = currNode->parent->Mconv * (glm::translate(glm::mat4(1), currNode->bone.position - currNode->parent->bone.position + bone->translate) * glm::mat4_cast(bone->quaternion));
                        }
                    }


                    traversal.pop();//弹出栈
                    for (auto iter = currNode->childs.rbegin(); iter != currNode->childs.rend(); iter++) {//将当前节点所有子骨骼压入栈中
                        traversal.push(*iter);
                    }
                } while (!traversal.empty());//如果还有骨骼没有解析,就继续解析
            }

            for (Animation::BNode* node : boneManager.linearList) {
                node->Mconv *= glm::translate(glm::mat4(1), -node->bone.position);
            }//对所有骨骼空间加上骨骼空间的初始逆变换。
        }

        void setUniform(Shader* shader) {//给Vertex Shader
            glm::mat4* m = new glm::mat4[boneManager.linearList.size()];
            for (int i = 0; i < boneManager.linearList.size(); i++) {
                m[i] = boneManager.linearList[i]->Mconv;
            }
            glUniformMatrix4fv(glGetUniformLocation(shader->ID, "bones"), boneManager.linearList.size(), GL_FALSE, (const GLfloat*)m);
            delete m;
        }
    };
//VertexShader
#version 330 core

layout(location = 15) in vec3 aPos;
layout(location = 14) in vec3 aNormal;
layout(location = 13) in vec2 aTexCoord;
layout(location = 12) in vec4 boneIndexs;
layout(location = 11) in vec4 boneWeights;
layout(location = 10) in float weightFormula;

out vec2 TexCoord;
out vec3 FragPos;
out vec3 Normal;

uniform mat4 transform;
uniform mat4 rotateMat;
uniform mat4 scaleMat;
uniform mat4 viewMat;
uniform mat4 projMat;

//如果不愿意固定写法,可以改Shader类代码,反正Shader程序是运行时编译。
#define MAX_BONE 230
uniform mat4 bones[MAX_BONE];

void main(){

    vec4 newPosition = vec4(aPos, 1.0);
    vec4 newNormal = vec4(aNormal, 0.0);//法向量不需要移动

    int index1 = int(boneIndexs.x);
    int index2 = int(boneIndexs.y);
    int index3 = int(boneIndexs.z);
    int index4 = int(boneIndexs.w);

    if(weightFormula == 0){//BDEF1
        newPosition = bones[index1] * newPosition;
        newNormal = bones[index1] * newNormal;
    }else if(weightFormula == 1 || weightFormula == 3){//BDEF2 or SDEF
        newPosition = (bones[index1] * newPosition) * boneWeights.x + (bones[index2] * newPosition) * boneWeights.y;
        newNormal = bones[index1]*newNormal * boneWeights.x + bones[index2] * newNormal * boneWeights.y;
    }else if(weightFormula == 2 || weightFormula == 4){//BDEF4 or QDEF
        newPosition = (bones[index1] * newPosition) * boneWeights.x + (bones[index2] * newPosition) * boneWeights.y + (bones[index3] * newPosition) * boneWeights.z + (bones[index4] * newPosition) * boneWeights.w;
        newNormal = bones[index1]*newNormal*boneWeights.x + bones[index2]*newNormal*boneWeights.y + bones[index3]*newNormal*boneWeights.z + bones[index4]*newNormal*boneWeights.w;
    }

    gl_Position = projMat * viewMat * transform * rotateMat * scaleMat * newPosition;
    FragPos = vec3(transform * rotateMat * scaleMat * newPosition);

    Normal = vec3(rotateMat * scaleMat * newNormal);
    TexCoord = aTexCoord;
}

gl_test 窗口中便是程序生成的姿势动画。

腿上的姿势不对,是因为除了正向动力学外,还有反向动力学,请看我的下一篇文章:

骨骼动画理论及程序实现(二)反向动力学

其他

理论上前向动力学的应用差不多到此为止了,不过 MikuMikuDance 本身还是有其他坑,因此此部分可以跳过。

我们前往 MMD,关闭所有 IK 解算:

可以发现,就算没有 IK 解算,腿部骨骼姿势也并非两腿站直,虽然这样的腿部姿势也不错,但这并非我们想要的。

打开 PMXEditor,随意选择腿上的一个顶点,观察影响顶点的骨骼:

是左足(既大腿根)和左膝盖(hiza)的 D 骨,观察这种骨骼的属性

可以发现,这些 D 骨都有 “赋予亲” 的选项,或者说,这些骨骼有两个亲骨;这种骨骼在大腿、胳膊、眼睛上都有,和赋予亲骨位置相同。

我们之前的程序不完全正确,是因为完全没考虑赋予亲的问题。

如果继续观察,可以发现,赋予亲骨和赋予子骨拥有共同的亲骨,例如左膝和左膝 D 的亲骨都是左足。

如果问为何这样设计?我想是这样,IK 链上的节点为了 IK 解算,不吃前向动力学,也就是说你怎么扭,骨骼都很别扭,有了 D 骨作为中间层,默认情况不更改 D 骨的 tranform 不会和 IK 解算冲突,如果想要前向动力学操控腿,不需要关闭 IK,只要更改 D 骨就可以了。
  由此需要改动的地方增加了不少,赋予亲由于其拥有赋予权重的概念,不能简单的当做亲子骨来看。首先要更改 BNode 的存储结构:

struct BNode {
    BNode(PMX::Bone& _bone) : bone(_bone) {};
    int32_t index;
    PMX::Bone& bone;
    BNode* parent = nullptr;
    std::vector<BNode*> childs;
    //新增的赋予亲上下级索引
    bool haveAppendParent = false;
    BNode* appendParent = nullptr;
    float appendWeight;
    std::vector<BNode*> appendChilds;

    glm::vec3 position;
    glm::quat rotate;
}

构建骨骼树时,要给相应值初始化。

if (node->bone.haveAppendRotate() || node->bone.haveAppendTranslate()) {
    node->haveAppendParent = true;
    node->appendParent = linearList[node->bone.appendParentBoneIndex];
    node->appendWeight = node->bone.appendWeight;
    linearList[node->bone.appendParentBoneIndex]->appendChilds.push_back(node);
}

然后,前向动力学遍历树的地方,如果有赋予亲另算:

if (currNode->haveAppendParent) {//有赋予亲的另算
    std::string parentName = model->bones[currNode->bone.appendParentBoneIndex].localName;
    Bone* appendParentRecord = (*file)[parentName];
    glm::vec3 totalTran(0);//默认的移动和旋转
    glm::quat totalRot = glm::quat(1, 0, 0, 0);
    if (appendParentRecord != nullptr) {//如果赋予亲在pose文件中有存储
        if (currNode->bone.haveAppendTranslate()) {
            totalTran = appendParentRecord->translate * currNode->appendWeight;
        }
        if (currNode->bone.haveAppendRotate()) {
            totalRot = glm::quat(glm::eulerAngles(appendParentRecord->quaternion * currNode->appendWeight));
        }
    }
    if (bone != nullptr) {//如果自身有记录
        totalTran += bone->translate;
        totalRot *= bone->quaternion;
    }
    currNode->position = currNode->parent->getLocalMat() * glm::vec4(currNode->bone.position - currNode->parent->bone.position + totalTran, 1);
    currNode->rotate = currNode->parent->rotate * totalRot;
}

如此,显示的画面和 MMD 中就一致了。

引用

[原创] 骨骼运动变换的数学计算过程详解
很经典的博客,骨骼初始逆变换我是在看到一些 Github 骨骼动画源码才知晓的,百度了一下发现了这个文章,真的不错!

借物:model 女仆丽塔 - 洛丝薇瑟 2.0 来自神帝宇

评论