最初这篇教程我并不打算作为第9章发布,原计划是第10章。在深入了解Opengl ES 2.0 和着色器之前,我想讨论下更基础的:动画。
注意:你可以在这里找到这篇教程的配套代码,新版本的代码已经在西部时间10:14更新了,更新的代码里面修正了一个不能动画的错误。
目前为止,想必你已经看过了opengles最基本的动画形式。通过随时间改变rotate, translate,
scale(旋转、移动和缩放)等,我们就可以使物体“动起来”。我们的第一个项目 the spinning
icosahedron就是这种动画的一个例子。我们把这种动画叫做简单动画。然而,不要被“简单动画”这个名称迷糊,你可以实现复杂的动画,只需要随时
间改变一下矩阵变换。
但是,如何掌握更加复杂的动画呢?比如说你想让一个人物行走或者表现一个被挤压正要反弹的球。
实际上这并不困难。在OpenGL了里面有两种主要实现方法:关键帧动画和骨骼动画。在这章里面我们谈论关于帧动画的话题,下一章(#9b)里面,我们将要谈论的是骨骼动画。
Interpolation & Keys
动画只不过是随着时间改变每个顶点的位置。这是是动画的本质。当你移动、旋转或缩放一个物体的时候,你实际上是移动了一个物体的所有顶点。如果你想让一个物体有一个更复杂、精细的动画,你需要一个方法按设置时间移动每个顶点。
两种动画的基本原理是存储物体关键位置的每一个顶点。在关键帧动画中,我们存储独立关键位置的每一个顶点。而骨骼动画,我们存储虚拟骨骼的位置信息,并且用一些方法指定哪个骨骼会影响动作中的哪些顶点。
那么什么是关键帧?如果要最简单的方法说明他们,我们还得回到他们的起源,传统逐格动画,如经典的迪斯尼和华纳兄弟的卡通。早期的动画,一个小的团队就能完成所有的绘画工作。但是随着产品的慢慢变大,那变得不可能,他们不得不进行分工。比较有经验的漫画师成为lead animator(有时叫关key animator)。这些有经验的画师并不画出动画的每一格,而是绘制更重要的帧。比如说一个极端的运动或姿势,体现一个场景的本质。如果要表现一个人物投掷一个球的动画,关键帧是手臂最后端时候的帧,手臂在弧线最顶端的帧,和人物释放球体的帧。
然后,key animator会转移到新场景 而 另一个in-betweener(有时叫rough in-betweener)会算出关键帧之间的时间间隔,并完成这些关键帧之间帧的绘画。比如一个一秒钟的投掷动画,每秒12帧,他们需要指出怎样在首席动画师绘制的关键帧中间完成剩下的9帧。
三维关键帧动画的概念也是一样。你有动作中关键位置的顶点数据,然后插值算法担当rough in-betweener的角色。插值将是你在三维动画里面用到的最简单的数学算法。
或许我们看一个实际的例子会更明白一点。让我们只关注一个顶点。在第一个关键帧,假设是在原点(0 ,0, 0)。第二个关键帧,假设那是在(5、5、5),并且在这两个关键帧之间的时间间隔是五秒(为了计算方便)。
动画的一秒钟,我们只需要表现出这一秒前后两个顶点在每个坐标轴上的变化。所以,在我们的例子中,两个关键帧在x,y,z轴总共移动了5个单位(5减去0
等于5)。一秒钟的动画走了1/5的路程,所以我们添加5的1/5到在第一关键帧的x,y,z轴上面,变成(1, 1,
1)。目前数值算出来的过程并不优雅,但是数学算法是一样的。算出总距离,算出与第一关键帧之间流逝的时间比例,两种相乘再加上第一关键帧的坐标值。
这是最简单的插值,叫线性插值,适用于大部分情况。更加复杂的算法,要权衡动画的长度。例如在Core
Animation中,提供了几种"ease in", "ease out", or "ease
in/out"等几种选项。也许我们会在以后的文章中讨论非线性插值。不过现在,为了保持简单易懂,我们继续讨论线性插值。你可以通过改变关键帧的数量和
它们的时间间隔,完成绝大多数动画。
Keyframe Animation in OpenGLES
让我们看一个OpenGL中简单动画的例子。当一个传统的手工绘画师被训练以后,他们做的第一件事情就是做一个能够被挤压的而且正在反弹的小球。这同样适合我们,程序会像下面这样:
让我们用 Blender(或者任何你想用的3d程序,如果你有方法输出vertex , normal data的数据用人工的方法。在这个例子里面我会用Blender export script,它能生成一个有顶点数据的头文件)创建一个球。
我开始在原点创建一多面体,并且重新命名为Ball1,然后我保存这个文件。使用我的脚本渲染并且输出ball1。你可以在这里找到这个帧的渲染文件。
现 在,我们按另存为(F2)保存一个Ball2.blend的副本。我重命名为Ball2以便于输出脚本使用不同的名字命名数据类型。接着点击 tab键进入编辑模式,点击A移动和缩放球体上的点,直到球体被压扁。保存压扁的球然后输出到Ball2.h。 你可以在这里找到压扁的球的资料。
到这里,我们有两个头文件,每个文件里面都包包含着我的动画里面要用到的每个帧的顶点数据。从my OpenGL ES template开始工作,我先在 GLViewControler.h定义了一些新的值,它能帮助我追踪小球的运动。
#define kAnimationDuration 0.3 enum animationDirection { kAnimationDirectionForward = YES, kAnimationDirectionBackward = NO }; typedef BOOL AnimationDirection;
因为我将是球在2个关键帧直接来回移动,我需要记录他的轨迹是向前或向后。我也设置一个值去控制两个帧之间的运动速度。
然后在 GLViewController.m里面,我重复在两个帧之间插值,如下(不要担心,我会解释的):
- (void)drawView:(UIView *)theView { static NSTimeInterval lastKeyframeTime = 0.0; if (lastKeyframeTime == 0.0) lastKeyframeTime = [NSDate timeIntervalSinceReferenceDate]; static AnimationDirection direction = kAnimationDirectionForward; glClearColor(1.0, 1.0, 1.0, 1.0); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glLoadIdentity(); glTranslatef(0.0f,2.2f,-6.0f); glRotatef(-90.0, 1.0, 0.0, 0.0); // Blender uses Z-up, not Y-up like OpenGL ES static VertexData3D ballVertexData[kBall1NumberOfVertices]; glColor4f(0.0, 0.3, 1.0, 1.0); glEnable(GL_COLOR_MATERIAL); NSTimeInterval timeSinceLastKeyFrame = [NSDate timeIntervalSinceReferenceDate]
- lastKeyframeTime; if (timeSinceLastKeyFrame > kAnimationDuration) { direction = !direction; timeSinceLastKeyFrame = timeSinceLastKeyFrame - kAnimationDuration; lastKeyframeTime = [NSDate timeIntervalSinceReferenceDate]; } NSTimeInterval percentDone = timeSinceLastKeyFrame / kAnimationDuration; VertexData3D *source, *dest; if (direction == kAnimationDirectionForward) { source = (VertexData3D *)Ball1VertexData; dest = (VertexData3D *)Ball2VertexData; } else { source = (VertexData3D *)Ball2VertexData; dest = (VertexData3D *)Ball1VertexData; } for (int i = 0; i < kBall1NumberOfVertices; i++) { GLfloat diffX = dest[i].vertex.x - source[i].vertex.x; GLfloat diffY = dest[i].vertex.y - source[i].vertex.y; GLfloat diffZ = dest[i].vertex.z - source[i].vertex.z; GLfloat diffNormalX = dest[i].normal.x - source[i].normal.x; GLfloat diffNormalY = dest[i].normal.y - source[i].normal.y; GLfloat diffNormalZ = dest[i].normal.z - source[i].normal.z; ballVertexData[i].vertex.x = source[i].vertex.x + (percentDone * diffX); ballVertexData[i].vertex.y = source[i].vertex.y + (percentDone * diffY); ballVertexData[i].vertex.z = source[i].vertex.z + (percentDone * diffZ); ballVertexData[i].normal.x = source[i].normal.x + (percentDone * diffNormalX); ballVertexData[i].normal.y = source[i].normal.y + (percentDone * diffNormalY); ballVertexData[i].normal.z = source[i].normal.z + (percentDone * diffNormalZ); } glEnableClientState(GL_VERTEX_ARRAY); glEnableClientState(GL_NORMAL_ARRAY); glVertexPointer(3, GL_FLOAT, sizeof(VertexData3D), &Ball2VertexData[0].vertex); glNormalPointer(GL_FLOAT, sizeof(VertexData3D), &Ball2VertexData[0].normal); glDrawArrays(GL_TRIANGLES, 0, kBall1NumberOfVertices); glDisableClientState(GL_VERTEX_ARRAY); glDisableClientState(GL_NORMAL_ARRAY); }
首先,有一些初始化设置。我创建了一个静态变量来追踪当前帧是否是最后一帧,这用来判定当前流逝的时间。首先我们初始化当前的时间,然后声明变量来追踪我们的动画是向前还是向后的。
static NSTimeInterval lastKeyframeTime = 0.0; if (lastKeyframeTime == 0.0) lastKeyframeTime = [NSDate timeIntervalSinceReferenceDate]; static AnimationDirection direction = kAnimationDirectionForward;
然后是一些OpenGL ES一般设置。唯一需要注意的是我把x轴旋转了-90°。我们知道OpenGL ES使用Y轴向上的坐标体系,同样的我们旋转为Z轴向上。
glClearColor(1.0, 1.0, 1.0, 1.0); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glLoadIdentity(); glTranslatef(0.0f,2.2f,-6.0f); glRotatef(-90.0, 1.0, 0.0, 0.0); // Blender uses Z-up, not Y-up like OpenGL ES
接下来,声明一个静态数组来存储插值数据:
static VertexData3D ballVertexData[kBall1NumberOfVertices];
为了简单,我设置了一个颜色并且开启了color materials。我不想使使用texture(纹理)或者materials(材质)使这个例子变得更加混乱。
glColor4f(0.0, 0.3, 1.0, 1.0); glEnable(GL_COLOR_MATERIAL);
现在我计算出上一个帧过去到现在的时间,如果这个时间大于动画时长,改变动画的运动方向。
NSTimeInterval timeSinceLastKeyFrame = [NSDate timeIntervalSinceReferenceDate]
- lastKeyframeTime; if (timeSinceLastKeyFrame > kAnimationDuration) { direction = !direction; timeSinceLastKeyFrame = timeSinceLastKeyFrame - kAnimationDuration; lastKeyframeTime = [NSDate timeIntervalSinceReferenceDate]; } NSTimeInterval percentDone = timeSinceLastKeyFrame / kAnimationDuration;
为了适应双向动画,我声明了两个指针指向源帧和目的帧的数据,并且根据当前的方向指向适当的数据数组。
VertexData3D *source, *dest; if (direction == kAnimationDirectionForward) { source = (VertexData3D *)Ball1VertexData; dest = (VertexData3D *)Ball2VertexData; } else { source = (VertexData3D *)Ball2VertexData; dest = (VertexData3D *)Ball1VertexData; }
最后,对于插值。正是我们前面谈论到的是一个相当普遍的线性插值:
for (int i = 0; i < kBall1NumberOfVertices; i++) { GLfloat diffX = dest[i].vertex.x - source[i].vertex.x; GLfloat diffY = dest[i].vertex.y - source[i].vertex.y; GLfloat diffZ = dest[i].vertex.z - source[i].vertex.z; GLfloat diffNormalX = dest[i].normal.x - source[i].normal.x; GLfloat diffNormalY = dest[i].normal.y - source[i].normal.y; GLfloat diffNormalZ = dest[i].normal.z - source[i].normal.z; ballVertexData[i].vertex.x = source[i].vertex.x + (percentDone * diffX); ballVertexData[i].vertex.y = source[i].vertex.y + (percentDone * diffY); ballVertexData[i].vertex.z = source[i].vertex.z + (percentDone * diffZ); ballVertexData[i].normal.x = source[i].normal.x + (percentDone * diffNormalX); ballVertexData[i].normal.y = source[i].normal.y + (percentDone * diffNormalY); ballVertexData[i].normal.z = source[i].normal.z + (percentDone * diffNormalZ); }
清理环境
glEnableClientState(GL_VERTEX_ARRAY); glEnableClientState(GL_NORMAL_ARRAY); glVertexPointer(3, GL_FLOAT, sizeof(VertexData3D), &Ball2VertexData[0].vertex); glNormalPointer(GL_FLOAT, sizeof(VertexData3D), &Ball2VertexData[0].normal); glDrawArrays(GL_TRIANGLES, 0, kBall1NumberOfVertices); glDisableClientState(GL_VERTEX_ARRAY); glDisableClientState(GL_NORMAL_ARRAY); }
不太难吧?只是些除法,乘法和加法。比起我们前面的经历,这算不了什么。这是基本技术的应用,例如在Id的老游戏里面使用的MD2文件格式。和我这里所作的一样,每个动画都使用了关键帧动画。Milkshape之后的版本支持其它文件格式,同样可以使用关键帧做复杂的动画。
如果你想检查这个弹球,你可以下载Xcode project亲自运行。
并不是所有的3 D动画都是用关键帧实现的,但是插值是复杂动画的基本原理。请继续关注part 9 b,我们将要使用插值实现一个被称为骨骼动画的更复杂的动画。
原文:iphonedevelopment,OpenGL ES from the Ground Up Part 9a: Fundamentals of Animation and Keyframe Animation
iTyran翻译讨论地址:http://ityran.com/forum-36-1.html
licensed under Creative Commons license.