7.4、高级模型
深入学习高级模型超出了本书的范围,但是可以介绍几个概念和术语,来为以后的学习打下基础。本节会讲解一些相当复杂的主题,这些主题通常是想要动画化人物模型或者动画化某些类型的网格模型的程序员感兴趣的。如果你急切地想进人下一个主题,那么可以跳过本节,但是本节有一些有趣的例子。
7.4.1 骨骼动画
骨骼动画(skeletalanimation)描述的是--个模型的造型和动画技术。网格提供了与前面例子相似的模型的可视部分,但是除此之外,还有一个不可见的用来变形网格的由“关节”和“骨头”相互连接组成的结构,这个结构又被称为骨骼(armature) 或者骨骼绑定(rig)。活动骨骼时,网格顶点也会跟着活动。例如,一个代表人物的腿和脚的网格会以代表踝关节、膝关节和髋关节的骨骼关节为中心来抬起和摇摆。术语骨骼动画暗示了它的主要用途就是活动用来模拟人物或者其他动物的模型,但是这个方法也适用于几乎所有的网格动画,包括第6章的飘扬旗帜的效果。
当动画化使用复杂网格的模型时,骨骼动画可以提供一个重要的优化。向GPU发送相对于几个骨骼关节的新方向,要快于重新计算并发送一个网格中每个顶点的位置。如在本节和下一节所讲解的,每个骨骼关节都是由一个变换矩阵控制的。GPU已经使用在第5章介绍的model-view矩阵和投影矩阵变换了每个顶点的位置。使用骨骼只为每个顶点增加了很少的额外计算。创建模型的设计师通常是通过直觉找出骨骼动画的。通过移动关节来摆一个手臂姿势要比手动地移动相应网格中的每个顶点容易很多。几乎所有支持动画的建模工具都使用骨骼动画。
骨骼关节由一个位置和一个变换矩阵组成。这个位置实际上是相对于一个可选的“父”关节的位移。从一个关节到下一个关节之间的位移代表了一个不可见的骨头。多个关节形成一个层次结构,使得父关节的变化可以影响所有与之相关的“子”关节。例如,当肘位置变化的时候,与之相连的腕也会移动,以保持肘和腕之间的距离不变。任意关节的位置都可以使用它父关节的变换矩阵与这个关节自己的变换矩阵的级联计算出来。下面的代码会返回一个关节的累积变换。这段代码使用了5.5 节中的一个复合变换:
// Returns the cumulative matrix that includes parent transforms
- (GLKMatrix4 ) cumulativeTransforms
{
GLKMatrix4 result = GLKMatrix4Identity;
if(nil != parent)
(
result = [parent cumulativeTransforms ];
)
GLKVector3 d = self.cumulativeDisplacement; .
// Use the classic recipe for transform about a point:
// translate to the location of the joint, rotate, and
// translate back.
result = GLKMatrix4Translate(result, d.x, d.y, d.z);
result = GLKMatrix4Multiply( result, self .matrix);
result = GLKMatrix4Translate(result, -d.x, -d.y, -d.z);
return result;
}
例子OpenGLES_Ch7_2会帮助你可视化关节。从一个modelplist文件加载了三个简单模型。为了保持例子简单,模型的长度同时决定了简单模型。为了保持例子简单,模型的长度同时决定了关节之间的距离。在图7-5中显示的用户界面滑动条可以让你测试关节旋转时模型的效果。第一个关节,就是没有父关节的那个关节,位于最底部。

例子OpenGLES_Ch7_2
使用GPU来实现基于骨骼的动画。GLKBaseEffect 的一个新子类UtilityArmature-BaseEffect
会创建并使用一个自定义的OpenGLES
Shading Language程序。使用UtilityArmatureBaseEffect
的-prepareToDrawArmature
方法来请求基于骨骼的动画。继承来的“-prepareToDraw”
方法仍然能够工作,使用的是标准的GLKitShadingLanguage程序。UtilityArmatureBaseEffect
有一个jointsArray
属性,用来保存UtilityJoint
实例。在运行时更新关节属性,然后调用"-prepareToDrawArmature"
方法来发送信息,这里的信息指的是ShadingLanguage程序重新计算GPU当前内存中的顶占和法向量所需要的信息。
OpenGLES_Ch7_2ViewController 类包含的下面代码会创建三个关节并添加它们到例子的Utility ArmatureBaseEffect实例。关节位置是用加载模型的轴对齐边界框进行初始化的。关节位置和方向通常是直接从模型文件中加载来的,而不是计算出来的;本例使用这个尺寸的模型仅仅是为了方便和简单。
// Create collection of joints
UtilityJoint *bone0Joint = [ [UtilityJoint alloc]
initWithDisplacement:GLKVector3Make(0, 0, 0)
parent:nil];
float bone0Length = self.bone0.axisAlignedBoundingBox.max.y -
self.bone0.axisAlignedBoundingBox.min.y;
UtilityJoint *bone1Joint = [ [UtilityJoint alloc]
initwithDisplacement:GLKVector 3Make(0, bone0Length, 0)
parent:bone0Joint];
float bone1Length = self.bonel.axisAlignedBoundingBox.max.y -
self.bone1.axisAlignedBoundingBox.min.y;
UtilityJoint *bone2Joint = [ [UtilityJoint alloc]
initwithDisplacement:GLKVector3Make(0, bonelLength, 0)
parent:bone1Joint];
baseEffect.jointsArray = [NSArray arrayWithobjects:bone0Joint,bone1Joint,bone2Joint,nil];
每个模型都被赋给了一个单独的关节索引,使用与:[self.bone0 assignJoint:0]相似的表达式。关节索引最终会与模型网格内的每个顶点的其他属性一起保存。Shading Language程序会使用关节索引来查询每个顶点是由哪一个关节矩阵变换的。在Shading Language程序内,每个关节矩阵都已经与投影矩阵、model-view矩阵和它的父关节的矩阵级联起来了,因此每个顶点(如这个关节自身、这个关节的父关节、这个关节的祖父关节等)都是使用层次结构中的所有关节的累加影响来变换的。
需要定义新的常量来添加额外属性。OpenGLES2.0并没有为属性常量赋予任何内在意义。数据的解析取决于Shading Language程序。由苹果的GLKBaseEffect类生成的Shading Language程序会赋予常量含义,这些常量包括GLKVertexAttribPosition、 GLKVertexAttribNormal、GLKVertexAttribColor、 GLKVertexAttribTexCoord0 和 GLKVertexAttribTexCoord1。除了GLKVertexAttribColor常量外,其他常量在前面的例 子中都使用过。
typedef enum {
UtilityArmatureVertexAttribPosition = GLKVertexAttribPosition,
UtilityArmatureVertexAttribNormal = GLKVertexAttribNormal,
UtilityArmatureVertexAttribTexCoordo = GLKVertexAttribTexCoord0,
UtilityArmatureVertexAttribTexCoordl = GLKVertexAttribTexCoord1 ,
UtilityArmatureVertexAttribJointMatrixIndices = UtilityArmatureVertexAttribJointNormalizedWeights,
} UtilityArmatureVertexAttrib;
UtilityArmatureBaseEffect重定义了GLKit的属性并且添加了两个新的属性,Utility-ArmatureVertexAttribJointMatrixIndices和UtilityArmatureVertexAttribJointNormalizedWeights。这个例子的UtilityMesh类会把关节信息保存在一个缓存中,并会以一个与其他每顶点属性相似的方式向GPU发送额外的值。
glEnableVertexAttribArray(
UtilityArmatureVertexAttribJointMatrixIndices);
glVertexAttribPointer(
UtilityArmatureVertexAttribJointMatrixIndices,
4, // number of coordinates
GL_FLOAT, // data is floating point
GL_FALSE, // no fixed point scaling
sizeof(UtilityMeshJointInfluence),// bytes per vert
(GLubyte *)NULL +
offsetof(UtilityMeshJointInfluence, jointIndices));
glEnableVertexAttribArray(
UtilityArmatureVertexAttribJointNormalizedWeights);
glVertexAttribPointer(
UtilityArmatureVertexAttribJointNormalizedWeights,
4, // number of coordinates
GL_FLOAT, // data is floating point
GL_FALSE, // no fixed point scaling
sizeof(UtilityMeshJointInfluence),// bytes per vert
(GLubyte *)NULL +
offsetof(UtilityMeshJointInfluence, jointWeights));
管理额外顶点属性的代码使用了与UtilityMeshVertex数据结构相似的数据结构UtilityMeshJointInfluence。下一节会讲解UtilityMeshJointInfluence和关节权重。OpenGLES_Ch7_2ViewController类还包含移动滑动条时更新关节的代码。下面的方法接收滑动条返回的浮点值,并会使用这个值来旋转jointsArray中索引为0的关节。用滑动条的值乘以MPI和0.5表示在正负90度的范围内调节第一个参数的弧度值。相似的方法更新了其他的两个关节。
- (void)setJoint0AngleRadians:(float)value
{
joint0AngleRadians = value;
GLKMatrix4 rotateZMatrix = GLKMatrix4MakeRotation(
value * M_PI * 0.5, 0, 0, 1);
[(UtilityJoint *)[baseEffect.jointsArray objectAtIndex:0]
setMatrix:rotateZMatrix];
}
几乎可以为关节设置任意的变换。绕着Z轴旋转只是为了保持例子的简单性。试着改变代码,以绕着X轴旋转或者平移。缩放也可以正常工作,但是当缩放因数不均匀时(在一个方向上的缩放幅度与其他方向的缩放幅度不一致),Shading Language程序内的光线方程式就会得出低质量的结果。
与第5章的一些例子中的UtilityTextureTransformBaseEffect类一样,相关例 子包含了UtilityArmatureBaseEffect类的源代码以及它的Shading Language程序。UtilityArmatureBaseEffect会从关节层次结构中提取出包含累积变换的关节矩阵,并把它们与model-view矩阵和投影矩阵级联。与此同时,它还为变换法向量计算新矩阵。UtilityArmatureBaseEffect会向GPU发送一个结果矩阵的数组。每个顶点的UtilityArmature- VertexAttribJointMatrixIndices属性决定了每个顶点会受UtilityArmatureBaseEffect提供的数组中的那些矩阵的影响。
注意
法向量变换矩阵是model-view累积变换的逆矩阵的转置矩阵。如果你现在不理解的话,不用担心。这是一个很少需要直接注意的小细节。GLKBaseEffect 会自动按需计算“法线矩阵”。类似的,UtilityArmatureBaseEffect 类会按需计算一个“法线矩阵”的数组。
图7-6展示了关节矩阵和每个顶点的Utility ArmatureVertexAttribJointMatrixIndices 属性之间的关系。同一时间内UtilityArmatureBaseEffect可以支持多个关节矩阵,但是并不是每个矩阵都被每个顶点所引用。

7.4.2 蒙皮
如图7-5所示,当关节方向变化时,模型之间会出现缺口。在例子OpenGLES_Ch7_2中产生的这个结果是因为每个顶点只受一个关节的影响,并且不同的“骨头”是用不同的模型绘制的。当一个单独的网格内的顶点直接受到多个关节的影响时,就有可能获得更有机、更平滑的网格动画。为了实现这一点,需要在每个顶点内保存多个关节索引。Shading Language程序会查询每个顶点引用的所有关节。每个关节对于一个顶点 的最终位置的影响取决与缩放因数,又叫做权重(weights)。 一个关节可能有50%的影响力,另一个关节可能有30%的影响力,然后还有两个关节每个起10%的作用。这个比率并不重要,只要总影响力加起来等于100%。
图7-7包含了例子OpenGLES_Ch7_3的两个屏幕截图,并且显示了多个关节联合影响的效果。设置每个顶点的关节索引和权重的过程就叫做蒙皮(skinning)。按比例混合多个关节对于每个网格顶点的影响力来变形网格,就好像在骨骼上拉伸皮肤。

OpenGLES_Ch7_3使用了与OpenGLES_Ch7_2相同的关节,但是绘制了一个单独的叫做“tube”的大网格,而不是多个不同的骨头模型。把玩一下例子OpenGLES_Ch7_3,感受一下移动滑动条时所产生的网格变形效果。
平滑的网格变形是通过多次变换每个网格顶点的位置来实现的:每个索引关节计算一次。如果得到的多个变换位置被求出平均值,那么计算出来的位置就相当于每个索引关节施加相等的影响力的蒙皮效果。权重提供了额外的控制力。使用权重后,一个关节可能有一个大的影响力,同时另一个关节却有一个非常小的影响力。
例子OpenGLES_Ch7_3中的“Rigidskin:”用户界面开关可以切换两种不同的“皮肤”。图7-7中的左图会按比例加重每个关节的影响力,这个比例的依据是这个关节与每个顶点之间的距离。距离远的关节比附近的关节影响小当‘RigidSkin:”开关处于“开”状态时,会设置权重以让每个顶点只受到这个顶点下面离它最近的关节的影响,但是别忘了,每个关节已经包含了它的父关节对其的影响。使用蒙皮可以创建类似带有皮肤的人物的复杂模型,并且这些皮肤会逼真地随着关节的活动而变形。当膝盖弯曲时,这个膝盖附近的皮肤会拉伸,但是大腿附近的皮肤不会受影响。
注意
COLLADA文件能够存储关节和权重。网页: https://www.wazim.com/Collada_Tutorial_1.htm 解释了怎么解析及使用COLLADA关节和权重。COLLADA文件还存储了多个与关节方向相关的设置,这为骨骼动画又添加了另一维。方向的每 个设置都类似于OpenGLES_Ch7_3中的滑动条的位置。每个设置都会创建一个与众不同的“姿势”。一个设置可能会让人物的手臂放在两侧,头看向肩膀。另一个设置可能会让人物的手臂抬起,头低下。在两个方向设置之间插值可以产生随着时间从一个姿势过渡到另一个姿势的平滑动画。
例子OpenGLES_Ch7_3在UtilityModel类中实现了自动指定关节和权重的两个不同的算法。详细的代码并不是很重要,因为自动蒙皮的模型在实际的应用中很少使用。相比在运行时实时计算关节的影响,在加载模型剩余部分的同时从一个文件加载关节索引和权重的方式要更好一点。设计师会用线下建模工具指定权重,以此来实现对于蒙皮的精巧控制。例子OpenGLES_Ch7_3的两个方法,“-automaticallySkinSmoothWithJoints:"和“-automaticallySkinRigidWithJoints:",也要受到那个不利因素的影响,这个不利因素指的就是它们的工作同样需要访问网格顶点数据。理想情况下,在CPU控制内存中的网格顶点的副本会在被发送给GPU后尽快地被丢弃掉,但是为了支持重蒙皮的动态计算,这些网格数据就必须要保存在CPU控制的内存中。
例子OpenGLES_Ch7_2和OpenGLES_Ch7_3 都会使用下面的数据结构来存储每个顶点的关节索引和权重:
/////////////////////////////////////////////////////////////////
// Type used to store vertex skinning attributes
typedef struct
{
GLKVector4 jointIndices; // encoded float for Shading Language
GLKVector4 jointWeights; // weight factor for each joint index
}
UtilityMeshJointInfluence;
UtilityMeshJointInfluences有一些与生俱来的古怪与限制。每个顶点只会保存四个关节索引和四个关节权重。四个关节通常就足以产生一个逼真的蒙皮效果。请记住,多个关节可能被用来变形一个单独的网格。这些关节中的任何一个都可能潜在地影响网格中的一个单独顶点。每个顶点选择四个关节。虽然索引值通常是整数或者字节,但是关节索引是用GLKVector4数据结构中的四个浮点值来存储的。OpenGL ES 2.0 Shading Language不能接收每个顶点的整数或者字节属性。这是大部分现代嵌人式GPU的一个限制。OpenGL ES 2.0 Shading Language 会为将来的执行保留类型的名字,比如短整型和字节。
7.4.3 逆动力学和物理模拟
古希腊术语动力学通常指的是物体的移动。现代背景下,动力学指的是使用骨骼动画让人物摆一个姿势所需要的关节方向变化的序列。正动力学定义了关节方向变化的一个序列:旋转肩膀15度,接着旋转肘45度,然后旋转胯70度,最后旋转手指关节来挠头。逆动力学提出了一个更有趣和前卫的方法。首先是目标,手指触摸头顶,然后返回来找出实现这个目的所需的所有关节方向变化。
使用GPU优化的逆动力学系统,可以最大限度地减少消息发送的数量。可以简单地发送末尾关节(或者假想的骨头)的最终位置,而不用发送每个关节的最新位置。所有其他关节的方向都可以用请求的最终位置推导出来。逆动力学会变得非常复杂。为了正确工作,必须要遵守特定模型的约束条件。例如,手不允许从头部穿越达到头顶;肘不允许向后弯曲。
实时物理模拟,尤其是逆动力学是热点探讨主题。机器人公司、建模工具制造商、3D模拟器开发商都在竞相提供通用解决方案。与此同时,流行的开源“BulletPhysics” 引擎提供了很多作品。Bullet会检测两个骨骼组件之间的碰撞,这至少会防止手臂穿过躯干或者肘向后弯曲。这仅仅揭开了BulletPhysics引擎能力的表面。它可以结合Blender和SketchUp并导出COLLADA文件。在网址: https://pybullet.org/wordpress/ 中可以找到Bullet Physics以及它在Mac OS X平台,上的Xcode演示工程。
7.5 小结
代表3D物体形状的模型是由一个或者多个网格定义的。在运行时加载模型为很多3D应用提供了重要的灵活性。通过脱机预处理数据并用原生格式保存数据,避免了嵌入式系统加载模型时所需的复杂解析和数据转换。模型可以非常复杂,但是它们是由简单的几何属性组成的。本章所使用的模型数据格式没有任何特别。利用模型和网格这两个概念可以实现使用任意工具和任意格式的任意资源管道。
模型骨骼、骨骼动画和蒙皮会使用GPU在运行时变形网格。通过更新相对少量的关节矩阵,避免了更新大量的顶点数据,也避免了向GPU重复发送数据。设计师通过设定关节和权重来实现对于骨骼动画和蒙皮效果的精巧控制。COLLADA文件可以存储设计师提供的数据。骨骼还为物理模拟引擎检测碰撞提供了数据,包括模型与其他物体或者模型的一部分与自身的碰撞。 下一章会讲解特效。特效是用来增强渲染场景的外观的3D骗局和假象。特效可以让观察者在一个场景中看到更多实际不存在的细节。