自由落体运动的动画编程

April 08, 2017

先上最终效果图

生成这个动画,我们需要解决两个问题:

  1. 小球是怎样下落和反弹的?
  2. 如何用计算机来模拟?

第一个问题很简单,小球做自由落体运动,加速度为重力加速度 g ,速度

vt=v0+gtv_t = v_0 + g \cdot t

位移

h=v0t+12gt2h = v_0 \cdot t + \frac{1}{2} \cdot g \cdot t^2

小球的反弹是一个能量不断减少的过程,我们假设每次接触地面,除速度反向外,速率变为原来的 k (k<1)k~(k<1) 倍,即

v1=kv0v_1= -k \cdot v_0

计算机动画原理

第二个问题则比想象中要难。计算机和现实最大的不同在于,现实世界是连续的,而计算机模拟出来的世界是离散的。

在时间维度上,从 0s0 s1s1 s 这之间有无数个时刻,无论是 0.1s0.1s, 0.33s0.33s 还是 0.343215s0.343215s,如果你观察,现实都会有那么一丁点细微的不同。但是在计算机模拟中,只有 2525 个“不同”的世界(即 2525 帧): 0.00s0.00s, 0.04s0.04s, 0.08s0.08s, 0.12s0.12s, 0.16s  1.00s0.16s~…~1.00s. 这样,0s0s0.04s0.04s 之间,我们看到的是一张静态图片,0.04s0.04s0.08s0.08s, 我们看到的又是另一张静态图片,如此屏幕在这 1s1s 显示了 2525 张图片,幸运的事,我们的大脑也差不多只能处理这么点儿信息,于是,我们认为我们看到了“动画”。

按这个道理来说,只有亲临现场看到的表演才“完整”,而通过电视屏幕看到的,都只是算是“部分”表演而已。

解决第二个问题的核心在于,要告诉计算机,小球在某一特定的时刻(即“帧”),应该出现在屏幕的哪里。

简单粗暴的解决办法

def move(self):
    self.v_y += self.g
    #更新小球速度
    self.y += self.v_y
    #更新小球y坐标位置

def if_bounce(self):
    global ground
    return self.y + self.height > ground
    #判断小球是否达到地面
    
def bounce(self):
    self.v_y *= -self.k
    #速度反向

def update(self):
    if self.if_bounce():
        self.bounce()
    else:
        self.move()

轨迹图如下

可以看到,这个方法有几个 bug ,

  1. 由于做的是匀加速直线运动,小球每一帧所经过的距离并不是 vyv_y ,而是平均速度 xx 时间,即 vy+(vyg)21\frac{v_y + ( v_y - g ) }{2}\cdot 1
  2. 判断是否回弹时,实际上是判断小球在此帧是否“陷入”了地下。结果就是,小球真的会陷入地下。
  3. 由于第二个 bug 的原因,实际发生反弹的地方要比我们所定义的地面位置要低。即使我们把速度衰退率 kk 设为 1,小球也会越跳越低。
  4. 同样在第二个 bug 的基础上出现的另一个奇葩 bug,小球可能会在地面附近来回震荡。为什么?考虑回弹后的一帧,小球速度反向后,由于 一、能量减少,速率变为原来的 k (k<1)k~(k<1) 倍。二、重力加速度和速度方向相反,速率更加减小。这样,一帧之后小球可能并没有跳出陷入的地面,下一帧判断时,小球又会反向……

精确模拟

第一个 bug 容易解决,

def move(self):
    self.v_y += self.g
    #更新小球速度
    self.y += (self.v_y - 1 / 2 * self.g)
    #更新小球y坐标位置

第二个也容易解决,我们改为判断“假想的”下一帧是否会“陷入”地面,

def if_bounce(self):
    global ground
    return (self.y + self.height) + self.v_y + 0.5 * self.g  > ground:

下的两个 bug 的解决思路很简单:假设我们判断下一帧会回弹,那么只需要告诉计算机,回弹后小球会在哪里,就够了。

首先计算离地面距离,通过初末速度和位移的关系

vt2v02=2ghv_t^2 - v_0^2 = 2 \cdot g \cdot h

计算到达地面速度和所用时间,再计算回弹速度、剩余时间,但是需要注意——当速度越来越小越来越小时,在这一帧中,小球会不止一次地回弹。我们需要考虑这些多余的回弹,来计算 11 帧后最终的速度和位置。

def bounce(self):
    global ground
    h = ground - (self.y + self.height)
    #离地面的距离
    v0 = (self.v_y ** 2 + 2 * self.g * h) ** 0.5
    #到达地面的速度
    t_start = (v0 - self.v_y) / self.g
    #到达地面所用的时间
    
    v1 = self.k * v0 * -1
    #回弹速度
    t_remain = 1 - t_start
    #剩余时间
    
    t_middle = -2 * v1 / self.g
    #再一次弹跳(从地面出发到回到地面)用时
    _v1 = v1
    _t_remain = t_remain
    #记录速度和剩余时间
    
    # ---循环开始---
    while t_remain >= 0:
        _v1 = v1
        _t_remain = t_remain
        v1 = v1 * self.k
        t_middle = -2 * v1 / self.g
        t_remain -= t_middle
        self.v_y = _v1 + self.g * _t_remain
        h = - _v1 * _t_remain - 0.5 * self.g * _t_remain ** 2
        
    self.y = (ground - self.height) - h
    #1帧后最终的位置

现在看起来应该万事大吉了吧,但如果你运行这个程序,一定会死机的。因为,在最后一帧的跳动内,小球有无限次跳动,因此上面的 while 循环永远不会退出。

有限时间内无限次跳动?

古希腊数学家芝诺曾经提出了这样一个悖论,

阿基里斯是古希腊著名的跑步健将,但他是永远追不上一只乌龟的。假定他速度是乌龟的 1010 倍。让乌龟在阿基里斯前面 10001000 米处,然后同时起跑。

阿基里斯跑了 10001000 米,此时乌龟便领先他 100100 米;当阿基里斯跑完下一个 100100 米时,乌龟领先他 1010 米;当阿基里斯跑完下一个 1010 米时,乌龟前于他 11 米;当阿基里斯跑完下一个 11 米时,乌龟前于他 0.10.1 米……

所以,阿基里斯能够继续逼近乌龟,但永远不可能追上它。

这个悖论的解释是,无数个数加起来,可以是有限的。反过来说,有限大小的数,可以分成无限个数。

按数学的话来说,无穷级数能够收敛。

在有限的时间内,是可以做无限次运动的。上面的跳动即是这样的情况。小球第一次接触地面之后,到第二次接触地面,所用时间是

t=v1(v1)g=2v1gt=\frac{v_1-(-v_1)}{g} = \frac{2\cdot v_1}{g}

第二次到第三次,由于初始速度变为 k 倍,所用时间是

ktk \cdot t

第三次到第四次所用时间是

k2tk^2 \cdot t

……依此,第 n-1 次到第 n 次所用时间是

kn2tk^{n-2} \cdot t

到第n次所用的总时间为

m=2nkm2t=(1kn)1kt\sum_{m=2}^{n}{k^{m-2}\cdot t} = \frac{(1-k^n)}{1-k}\cdot t

速度衰退率 kk 小于 11,每次所用时间等比缩小,当 nn 趋近于正无穷时,极限存在,

limn(1kn)1kt=t1k=2v1g11k\lim_{n\rightarrow \infty}\frac{(1-k^n)}{1-k}\cdot t = \frac{t}{1-k} =\frac{2\cdot v_1}{g}\cdot\frac{1}{1-k}

v1v_1 很小时,所用时间小于 11(即 11 帧时间). 结果就是:在这 11 帧内,小球进行了无限次跳动,然后停了下来。

小球真的会停下来吗?

每次反弹,小球速率都变为 kk 倍,假设第一次接触地面是 v1v_1,第二次接触地面是 v1kv_1 \cdot k ,第 nn 次接触地面速度

v1knv_1 \cdot k^n

但是,无论接触地面多少次,这个数只可能无限地小,不可能是0啊!

这又是一个有趣的问题,类似于

0.999999999...0.999999999... 等于 11 吗?

0.333333333...0.333333333... 等于 13\frac{1}{3} 吗?

0.000000...0.000000... 无穷个 00 后面跟一个 11 等于 00 吗?

答案都是肯定的,一个解释是,对于两个不同的实数来说,必然存在另外一个数在它们之间,而不存在任意一个数,位于 0.9999999...0.9999999...11 之间,因此它们必然是一个数。其他两个回答同理。

但我更喜欢用极限的方式解释,0.99999...0.99999... 又可以写成

limn10.1n\lim_{n\rightarrow \infty}1-0.1^n

这个结果就是 11 。注意,做完极限运算后,值并不是“趋近于” 11,而是“就是” 11.

无论接触地面多少次,这个数只可能无限地小,但不可能是 00 ——这个论断错在哪里呢?数学归纳法思想,可以用于任意数——但必须是有限大小的数,而不能用于“无穷大”。正如我们用数学归纳法做结论时所说的,某某命题对所有正整数成立,意思是,这个命题对于任何一个正整数都成立,无论它们多大。但再大的正整数,都是有限大小的。而“无穷大”并不在正整数集合中,因此我们不能推断这个命题对“无穷大”也成立。

所以,当接触地面无穷次时,它的速度就是 00 了,即

limnv1kn=0\lim_{n\rightarrow \infty} v_1 \cdot k^n = 0

解决死循环

在进入循环前,我们可以先数学方法求无穷级数,获得所有弹跳所用的时间,如果时间在1帧内结束,那么可以确定1帧后小球停止跳动。

if t_middle / (1 - self.k) <= t_remain:
    #计算弹跳总时间,判断是否在1帧内结束
    self.stop()
    #如果结束,受到地面弹力,加速度为0
    else:
        # ---循环开始---
        while t_remain >= 0:

其中,

def stop():
    self.v_y = 0
    #结束跳动后,速度为0
    self.y = self.ground - self.height
    #结束跳动后,位于地面
    self.g = 0
    #结束跳动后,受到地面弹力,加速度为0

虽然死循环解决了,但最后几帧计算量仍然可能很大,我们可以牺牲一些精确性来提高运算速度,比如当速度小于某一个很小的阈值时直接让其停止,

def if_bounce(self):
    global ground
    if self.y + self.v_y + 0.5 * self.g + self.height > ground:
        if self.v_y < 0.1:
            self.stop()
        else:
            return True
    return False

轨迹图如下,我们已经完美地解决了四个 bug 。