前言
由于数学公式的渲染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保持一致,随后Child绕X旋转了90°,并向Parent的Y轴移动了一个单位,求Child坐标为的空间点在变换后在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本身还是有其他坑,因此此部分可以跳过。
打开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 来自神帝宇
网友评论