OpenGL ES 从零开始系列9a:动画基础和关键帧动画

最初这篇教程我并不打算作为第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,我们将要使用插值实现一个被称为骨骼动画的更复杂的动画。


你可能感兴趣的:(OpenGL ES 从零开始系列9a:动画基础和关键帧动画)