catnipan.com

动力学的程序实现:弹跳

2017-4-28

先上最终效果图

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

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

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

$$ v_t = v_0 + g \cdot t $$

位移

$$ h = v_0 \cdot t + \frac{1}{2} \cdot g \cdot t^2 $$

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

$$ v_1= -k \cdot v_0 $$

计算机动画原理

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

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

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

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

简单粗暴的解决办法

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. 由于做的是匀加速直线运动,小球每一帧所经过的距离并不是 v_y ,而是平均速度 x 时间,即 $$ \frac{v_y + ( v_y - g ) }{2}\cdot 1$$
  2. 判断是否回弹时,实际上是判断小球在此帧是否“陷入”了地下。结果就是,小球真的会陷入地下。
  3. 由于第二个bug的原因,实际发生反弹的地方要比我们所定义的地面位置要低。即使我们把速度衰退率 k 设为 1,小球也会越跳越低。
  4. 同样在第二个bug的基础上出现的另一个奇葩bug,小球可能会在地面附近来回震荡。为什么?考虑回弹后的一帧,小球速度反向后,由于 一、能量减少,速率变为原来的 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的解决思路很简单:假设我们判断下一帧会回弹,那么只需要告诉计算机,回弹后小球会在哪里,就够了。

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

$$ v_t^2 - v_0^2 = 2 \cdot g \cdot h $$

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

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 循环永远不会退出。

有限时间内无限次跳动?

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

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

阿基里斯跑了1000米,此时乌龟便领先他100米;当阿基里斯跑完下一个100米时,乌龟领先他10米;当阿基里斯跑完下一个10米时,乌龟前于他1米;当阿基里斯跑完下一个1米时,乌龟前于他0.1米……

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

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

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

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

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

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

$$k \cdot t$$

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

$$k^2 \cdot t$$

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

$$k^{n-2} \cdot t$$

到第n次所用的总时间为

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

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

$$\lim_{n\rightarrow \infty}\frac{(1-k^n)}{1-k}\cdot t = \frac{t}{1-k} =\frac{2\cdot v1}{g}\cdot\frac{1}{1-k}$$

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

小球真的会停下来吗?

每次反弹,小球速率都变为 k 倍,假设第一次接触地面是 v1,第二次接触地面是 v1 · k ,第n次接触地面速度

$$ v1 \cdot k^n $$

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

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

0.999999999….等于 1 吗?

0.333333333…等于 13 吗?

0.000000…无穷个0后面跟一个1 等于 0 吗?

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

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

$$ \lim_{n\rightarrow \infty}1-0.1^n$$

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

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

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

$$ \lim_{n\rightarrow \infty} v1 \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。

查看源代码