本节我们学习如何建立模型与模型之间的层级关系,层级关系通常用树形结构来表示。比如人体模型,人是由头部、躯干、胳膊、腿、脚等局部模型组成,当人在走路的时候,腿部相对于腰部做旋转运动,膝盖会相对于大腿内侧进行旋转,小腿会相对于膝盖进行旋转,这些不同部位间的相对运动通过多个矩阵变换的方式可以转化成相对世界坐标系的绝对运动,如下图,便是一个行走中的机器人:
同样地,可以类比到动物、汽车、太阳系、银河系、宇宙等,涉及到相对运动的多个物体之间我们都可以用层级对它们进行建模。
为什么需要层级建模
如果没有层级建模,那么模型只能做整体的刚性运动,模型的一部分不能相对整体进行局部运动,这在某些场景下就会产生一些问题。比如在游戏中的人物在行走时,手臂、腿会随着步伐做适当摆动,如果没有层级建模,我们就需要单独为手臂、腿计算相对世界坐标系的变换矩阵,比较复杂。如果有了层级建模,我们可以只考虑手臂、腿相对于自身的变换,不需再考虑世界坐标系下的变换。
不需考虑世界坐标系下的变换并不代表我们不计算世界矩阵,只不过计算世界矩阵的方法在已经被我们抽象出来,我们不用关心这块逻辑,而是交由库来实现。我们只需要设置好平移、旋转、缩放这三个属性即可。
我们需要用一棵树型结构来表示层级关系,节点与节点之间存在父子关系,即每个节点都会有 parent 和 children 属性,其中,parent 表示当前节点的父节点,children 表示当前节点的子节点属性,用数组来表示。除此之外,节点还需要保存世界变换矩阵和局部变换矩阵,世界变换矩阵用来保存子节点的世界变换矩阵,局部变换矩阵用来保存子节点自身的变换矩阵。
我们知道,变换分为 3 种:平移、缩放、旋转。每个节点都需要有这三个属性,但请大家一定要记住,在使用层级进行建模时,这三个属性都是相对于模型自身坐标系的。各个节点的世界变换矩阵如何得来呢?
- 1、通过平移、旋转、缩放属性我们可以计算出节点的局部变换矩阵,
- 2、将每个节点的父节点的世界变换矩阵右乘当前节点的局部变换矩阵可以计算出当前节点的世界变换矩阵。
我们以手臂的运动加以分析,手臂由前臂和小臂组成,小臂绕着肘关节(即前臂的末端)进行旋转,不需要考虑其他因素。手臂的层级表示如下:
let arm = {
name:'arm',
translation: [0, 0, 0],
rotation: [0, 0, 0],
scalation: [1, 1, 3],
parent:null,
children:[
{
name:'forearm',
translation: [0, 0, 0],
rotation: [0, 0, 0],
scalation: [1, 2, 1],
parent:null,
}
]
}
接下来我们看一下如何根据这些属性实现层级建模。
如何实现层级建模
还记得我们重构过的 Model 类吗?为了实现层级关系,我们需要为 Model
类增加 children
、parent
、localMatrix
、worldMatrix
四个属性:
function Model(name) {
this.uniforms = {};
this.bufferInfo = {};
this.u_Matrix = matrix.identity();
this.translation = [0, 0, 0];
this.scalation = [1, 1, 1];
this.rotation = [0, 0, 0];
this.parent = null;
this.children = [];
this.localMatrix = matrix.identity();
this.worldMatrix = matrix.identity();
this.name = name || '未命名';
}
另外,我们给模型增加设置父节点的方法:
Model.prototype.setParent = function(parent){
// 若当前模型有父节点,从父节点中移除
if(this.parent){
let index = this.parent.children.indexOf(this);
if(index >= 0){
this.parent.children.splice(index, 1);
}
}
// 将模型添加到指定 parent 节点的子列表尾部。
if(parent){
parent.children.push(this);
}
this.parent = parent || null;
}
在预渲染阶段,我们为模型计算 MVP 矩阵的时候,计算出来的只是相对于自身的局部变换矩阵,需要转化成相对世界坐标系的矩阵,转化过程也比较简单,将当前节点的本地矩阵右乘父节点的世界矩阵即可,我们需要有这样一个算法来更新模型的世界矩阵。
Model.prototype.getWorldMatrix = function(worldMatrix){
if (worldMatrix) {
this.worldMatrix = matrix.multiply(worldMatrix, this.localMatrix);
} else {
this.worldMatrix = matrix.clone(this.localMatrix);
}
let currentWorldMatrix = this.worldMatrix;
this.children.forEach(function(model) {
model.getWorldMatrix(currentWorldMatrix);
});
}
有了更新世界矩阵的方法,那么接下来要考虑的是在何时执行更新方法呢?还记得 Model 的preRender 方法吗,我们把 getWorldMatrix 方法的执行时机放在 preRender 中。
let modelMatrix = matrix.identity();
if (this.translation) {
modelMatrix = matrix.translate(
modelMatrix,
this.translation[0],
this.translation[1],
this.translation[2]
);
}
if (this.rotation) {
if (this.rotation[0] !== undefined)
modelMatrix = matrix.rotateX(modelMatrix, degToRadians(this.rotation[0]));
if (this.rotation[1] !== undefined)
modelMatrix = matrix.rotateY(modelMatrix, degToRadians(this.rotation[1]));
if (this.rotation[2] !== undefined)
modelMatrix = matrix.rotateZ(modelMatrix, degToRadians(this.rotation[2]));
}
modelMatrix = matrix.translate(
modelMatrix,
-this.origination[0] * this.scalation[0],
-this.origination[1] * this.scalation[1],
-this.origination[2] * this.scalation[2]
);
if (this.scalation) {
modelMatrix = matrix.scale(
modelMatrix,
this.scalation[0],
this.scalation[1],
this.scalation[2]
);
}
this.localMatrix = modelMatrix;
this.children.forEach(function(child) {
child.preRender(viewMatrix, projectionMatrix);
});
if (!this.parent) {
this.getWorldMatrix();
}
//重新计算 MVP 矩阵
this.u_Matrix = matrix.multiply(viewMatrix, this.worldMatrix);
this.u_Matrix = matrix.multiply(projectionMatrix, this.u_Matrix);
this.uniforms.u_Matrix = this.u_Matrix;
this.uniforms.u_ModelMatrix = this.worldMatrix;
你会发现,我在preRender方法中执行getWorldMatrix 时增加了一个判断条件,当前节点没有父节点时才开始执行世界矩阵的更新。
层级建模实战
好了,经过改造后的 Model 满足层级建模的需求了,我们实现一个手臂模型演示 Model 使用方法。
我们创建一个手臂 arm 和 前臂 forearm,简单起见,将他们的形状设置为顶点边长为 1 的立方体:
let cube = createCube(1, 1, 1);
let bufferInfo = createBufferInfoFromObject(gl, cube);
let arm = new Model('arm');
arm.setBufferInfo(bufferInfo);
let forearm = new Model('forearm');
smallArm.setBufferInfo(bufferInfo);
接下来要将 forearm 的父节点设置为arm,建立层级关系,并将 arm 添加到模型列表中,这里由于forearm 和 arm 建立了父子关系,所以此处我们只把arm 添加到模型列表和渲染列表即可。
forearm.setParent(arm);
objectList.add(arm);
renderList.add({
model: arm,
programInfo: program
});
接下来开始渲染,渲染仍然是遍历渲染列表,传递 渲染对象对应的模型对象的 uniforms 属性和 attribute 属性:
renderList.forEach(function(object) {
let programInfo = object.programInfo;
let programChanged = false;
object.model.preRender(viewMatrix, projectionMatrix);
if (programInfo != lastProgramInfo) {
lastProgramInfo = programInfo;
programChanged = true;
}
renderObject(
object.model,
programChanged,
object.renderType,
object.primitive
);
});
我们看下效果:
我们只看到了一个立方体,原因是我们没有给 forearm 设置平移,forearm和arm重叠了。
增加平移
为 forearm 设置一个平移属性:
forearm.translateX(1);
嗯,这次我们可以看到两个挨着的立方体了,靠右的是 forearm。
层级建模的一个优势是,我们只需要考虑局部的变换,不需要考虑世界坐标系变换,库会为我们自动计算。接下来我们演示一下,这次我们只让 arm 进行平移:
可以看到,不管 arm 移动到什么位置,forearm 始终在 arm 的 X 轴右侧 1 个单位处。
增加缩放
手臂过于方正了,我们将 arm 和 forearm 沿着X轴放大一倍:
arm.scaleX(2);
啊哦,我们只给 arm 设置了放大倍数,但是同时影响了 forearm,这在某些时候可不是我们想要的结果,在一些特定的场景下,我们能只对父节点执行缩放,而不缩放子节点。
如何解决这个问题呢?
事实上,我们可以将需要放大的节点拆出来,只对它执行缩放变换。像下面这样:
let arm = {
name: 'arm',
children:[{
name: 'bigArm',
rotation:[2, 1, 1]
},{
name: 'forearm',
translation: [1, 0, 0]
}]
}
额外增加一个需要缩放的节点 bigArm 作为 arm 的子节点,此时bigArm 和 forearm 同级,并且都以 arm 作为父节点。有一点需要注意,arm 此时不再需要渲染了,作为父节点的它,只是为了给子节点提供旋转和平移的变换矩阵,所以,我们修改Model ,为 Model 增加一个参数 isDraw,设置为 false即可不必对它执行渲染操作。
function Model(name, isDraw){
...略
if(isDraw === false){
this.isDraw = false;
}
else {
this.isDraw = true;
}
...略
}
let arm = new Model('arm', false);
let bigArm = new Model('bigArm');
bigArm.scaleX(2);
bigArm.setParent(arm);
forearm.translateY(1);
...略
效果如下:
可以看到,通过额外增加一个节点的方式,我们可以解决父节点的缩放效果影响到子节点问题。
增加旋转
解决了缩放问题,接下来我们增加旋转效果,这里我们让 forearm 绕自身原点旋转,默认情况下节点的坐标系原点位于中心位置。
可以看到,forearm 围绕自身中心在旋转了,但是这不是我们期待的效果,我们期待的是 forearm 绕 forearm 和 arm 先接触进行旋转。
如何解决这个问题,我们可以给 Model 增加一个新的属性,改变模型自身原点:
function Model = (name, isDraw){
...略
this.origination = [0, 0, 0];
...略
}
Model.prototype.setOrigin = funcition(ox, oy, oz){
this.origination[0] = ox || 0;
this.origination[1] = oy || 0;
this.origination[2] = oz || 0;
}
我们改变 forearm 原点到左侧:
forearm.setOrigin(-0.5, 0, 0);
试试看下效果:
嗯,这次 forearm 可以绕着自身左侧旋转了,但是还有一些问题,forearm 有一部分和 arm 重叠了,我们需要将 forearm 向右平移 bigArm 的长度,即 2 个单位。
forearm.translateX(2);
这回像个手臂了,但还有个问题, forearm 有些过于短小了,我们把它拉长一些:
forearm.scaleX(2);
嗯,这个动作看起来像是在秀肌肉了。
行走的机器人
我们利用上面的层级代码实现本节开头的机器人行走动画,首先画一下机器人的层级结构:
我们设计的机器人由上图中的节点组成,所有节点都是由边长为 1 的立方体通过缩放平移组合而成。
如果按照前面的开发方式,每个节点的生成、平移、缩放、旋转等都需要用代码来控制,比如这个机器人:
let person = new Model('person', false);
// 创建头部
let head = new Model('head');
head.setBufferInfo(bufferInfo);
head.translate(0, 2, 0);
head.scale(1.5,1.5,1.5);
head.setParent(person);
// 胸部
let torso = new Model('torso');
torso.setBufferInfo(bufferInfo);
torso.scale(2.2, 3, 2.2);
torso.setParent(person);
// 腰部
let waist = new Model('waist');
waist.setBufferInfo(bufferInfo);
waist.translate(0, -2, 0);
waist.setParent(person);
//左胳膊
...略
// 右胳膊
...略
...
你会发现,我们的节点越多,我们就要重复编写这些设置代码,比较繁琐,所以,我们最好能有一个描述机器人的结构体,通过一个方法解析这个结构体,自动生成person 对象。
我们用对象结构来描述模型之间关系,每个节点都有如下属性:
- name:节点名称
- isDraw:是否渲染
- scalation:缩放
- translation:平移
- rotation:旋转
- origination:原点位置
- children:子节点
- parent:父节点
- uniforms:当前节点的全局属性,用于传往着色器程序。
let description = {
name: 'person',
isDraw: false,
scalation: [0.4, 0.4, 0.4],
children: [
{
name: 'head',
scalation: [1.5, 1.5, 1.5],
translation: [0, 2, 0],
uniforms: {
u_ColorFactor: [0.5, 0.5, 0],
u_ColorOffset: [0.5, 0.5, 0]
}
},
{
name: 'torso',
scalation: [2.2, 3, 2.2]
},
{
name: 'leftArm',
isDraw: false,
translation: [-2, 1, 0],
children: [
{
name: 'leftBigArm',
scalation: [0.8, 1.5, 0.8],
origination: [0, 0.5, 0]
},
{
name: 'leftSmallArm',
scalation: [0.8, 1.5, 0.8],
origination: [0, 0.5, 0],
translation: [0, -1.5, 0]
}
]
},
{
name: 'rightArm',
isDraw: false,
translation: [2, 1, 0],
children: [
{
name: 'rightBigArm',
scalation: [0.8, 1.5, 0.8],
origination: [0, 0.5, 0]
},
{
name: 'rightSmallArm',
scalation: [0.8, 1.5, 0.8],
origination: [0, 0.5, 0],
translation: [0, -1.5, 0]
}
]
},
{
name: 'waist',
translation: [0, -2, 0],
children: [
{
name: 'leftLeg',
translation: [-1, 0.5, 0],
isDraw: false,
children: [
{
name: 'thigh',
origination: [0, 0.5, 0],
scalation: [1, 2, 1]
},
{
name: 'crus',
translation: [0, -2, 0],
isDraw: false,
children: [
{
name: 'crusScale',
origination: [0, 0.5, 0],
scalation: [1, 2, 1]
},
{
name: 'foot',
scalation: [1, 0.5, 1.6],
translation: [0, -2, -0.25]
}
]
}
]
},
{
name: 'rightLeg',
translation: [1, 0.5, 0],
isDraw: false,
children: [
{
name: 'thigh',
origination: [0, 0.5, 0],
scalation: [1, 2, 1]
},
{
name: 'crus',
translation: [0, -2, 0],
isDraw: false,
children: [
{
name: 'crusScale',
origination: [0, 0.5, 0],
scalation: [1, 2, 1]
},
{
name: 'foot',
scalation: [1, 0.5, 1.6],
translation: [0, -2, -0.25]
}
]
}
]
}
]
}
]
};
接下来我们要有一个能够解析这个结构体的方法,通过这个结构体能够返回模型对象:
function createModel(node) {
let model = new Model(node.name, node.isDraw);
if (model.isDraw) {
model.setBufferInfo(bufferInfo);
}
// 节点是否设置了平移
if (node.translation) model.translate(node.translation);
// 节点是否设置了旋转
if (node.rotation) model.rotate(node.rotation);
// 节点是否设置了缩放
if (node.scalation) model.scale(node.scalation);
// 节点是否设置了原点
if (node.origination) model.setOrigin(node.origination);
// 节点是否设置了全局变量
if (node.uniforms) model.setUniforms(node.uniforms);
// 节点是否有子节点,若有,遍历子节点
if (node.children) {
node.children.forEach(function(childNode) {
let childModel = createModel(childNode);
childModel.setParent(model);
});
}
// 返回模型对象。
return model;
}
利用这种方式,我们就不必每次都编写重复的设置代码了,只需要修改表示模型的结构体就可以了。
利用上面的方法创建 person 对象就很简单了:
let person = createModel(description);
有了机器人模型,接下来我们要让它动起来。 首先,机器人走动时整体会移动。 其次,胳膊和大腿会有幅度地摆动,同时,前臂和小腿会随着胳膊和大腿进一步摆动。
在每次 render 的时候,更新 person
节点的 translate 平移属性,同时更新以下节点的旋转属性,假设某一刻,左臂旋转角度为 N 度时:
- 左臂旋转 N 度
- 左前臂在左臂的基础上额外旋转 N / 2 度
- 右臂旋转 -N 度
- 右臂旋转 -N 度代表往后旋转,此时右前臂不设置旋转。
- 左腿旋转 -N 度
- 左腿旋转 -N 度,代表往后摆动,此时左小腿设置旋转。
- 右腿旋转 N 度
- 右小腿在右腿的基础上额外旋转 N / 2 度
分析出模型以及模型局部的运动规律之后,我们就可以在每一帧动画中改变他们的属性了:
function animate(person){
let object = person.moveInfo;
if (Math.abs(object.leftRotation) > rotationMax) {
object.leftIndex = -object.leftIndex;
}
object.leftRotation += object.leftIndex;
object.rightRotation += -object.leftIndex;
object.leftLegRotation -= object.leftIndex;
object.rightLegRotation += object.leftIndex;
if (Math.abs(person.translation[2]) > 4) {
object.moveIndex = -object.moveIndex;
}
person.translateZ(
calFloat(person.translation[2], -0.2 * object.moveIndex)
);
if (
Math.abs(person.translation[2]) > 3 &&
person.translation[2] * object.moveIndex > 0
) {
let unitAngle = (180 * object.moveIndex) / 5;
person.rotateY(person.rotation[1] + unitAngle);
}
person.children.forEach(function update(child) {
if (child.name == 'leftArm') {
child.rotateX(object.leftRotation);
}
if (child.name == 'rightArm') {
child.rotateX(object.rightRotation);
}
if (child.name == 'leftSmallArm') {
if (object.leftRotation > 0) {
child.rotateX(object.leftRotation / 2);
}
}
if (child.name == 'rightSmallArm') {
if (object.rightRotation > 0) {
child.rotateX(object.rightRotation / 2);
}
}
if (child.name == 'rightLeg') {
child.rotateX(object.rightLegRotation);
}
if (child.name == 'leftLeg') {
child.rotateX(object.leftLegRotation);
}
if (child.name == 'crus' && child.parent.name == 'leftLeg') {
if (object.leftLegRotation > 0) {
child.rotateX(-object.leftLegRotation);
} else {
child.rotateX(object.leftLegRotation / 2);
}
}
if (child.name == 'crus' && child.parent.name == 'rightLeg') {
if (object.rightLegRotation > 0) {
child.rotateX(-object.rightLegRotation);
} else {
child.rotateX(object.rightLegRotation / 2);
}
}
child.children.forEach(update);
});
}
核心代码就是以上这些了,大家可以点击这里查看完整代码。
回顾
以上就是层级建模的思路以及实现方法,主要是利用了矩阵乘法将节点间的世界变换矩阵和本地变换矩阵串联起来,从这里我们更能感受到矩阵和坐标系变换的重要性。
层级建模原理比较简单,但前提是大家深刻理解了矩阵的运算法则以及坐标系变换的原理本质。如果大家模棱两可,那说明还是没有真正掌握,还需要下功夫搞明白。