前言

继第一篇文章之后已经过去了两个月,在上一篇文章中介绍了物理引擎是什么,需要掌握什么样子的基础知识才能继续往下进行开发。在这样的基础上,我们展开了第二篇,探索物理引擎的学习之路。在我们的日常开发当中,自然是用不到非常复杂的物理系统,大部分游戏都是基于刚体,再在游戏场景下进行一定的适配,最后模拟出物体在我们常规认识中的运动状态,使我们觉得这些位移,形变看起来都是理所当然,顺应规律的。其中最出名的手机游戏莫过于《愤怒的小鸟》了。那么我们如何达到《愤怒的小鸟》中的效果呢?让我们一步步来探索。

不懂物理的前端不是好的游戏开发者(二)—— 物理引擎的学习之路-LMLPHP

粒子在游戏中的运动

首先我们来看一下这样的一个场景,在现实中,一个普通子弹的速度大约是 300m/s,接近音速,如果将其放在游戏当中,依然按照正常的速度,那么射出去的子弹只会在一瞬间消失不见,那射击游戏就成了玩家完全无法观察到弹道的游戏,横版过关类的游戏也变成了只看得到枪口火焰,但是看不到弹幕的恐怖游戏。

不懂物理的前端不是好的游戏开发者(二)—— 物理引擎的学习之路-LMLPHP

那作为“子弹”的小鸟也是同理,如果是真的按照在空中飞行的速度去计算,那么我们只能看到一个矫健的身影一闪而过,然后就像子弹一样打穿建筑飞往远方了。这显然不是我们想要的效果。

那么要如何解决这样的问题呢?显而易见的,我们应该降低游戏中高速物体的速度。一般来说,根据地图的大小,可以将速度限定在 5-25m/s。那么速度的问题解决了,但是当一个物体的速度从 300 降低到了 25 的时候,它的能量,碰撞的现象,都会有很大的不同。当一个慢悠悠的小鸟撞到一堵墙的时候,可能只会默默掉下来,而墙表示:“刚才什么东西给我挠了一下痒?”。那如何去解决这样的问题?

首先我们都知道动能和速度以及质量的关系:$E=\frac{1}{2}mv^2$,那么当速度下降的时候,我们就需要增加质量,两者的关系为,质量上升的比例等于速度下降的比例,也就是 $\Delta m$ = $\Delta s^2$。那当一个质量为 5g,速度 300m/s 的子弹加速度降低到 25m/s 的时候,质量应该上升 144 倍,变为 720g。这样我们便解决了速度降低导致能量降低的问题。此时我们的小鸟已经从一个普普通通的小鸡仔变成了一个结实有力的肌肉猛鸟,只需要动一动,就能把野猪撞飞。

但是又出现了新的问题,速度降低随之而来的是抛物线的变形,原本可以飞更远的,但是由于初速度过小,没多远就下坠到地面了,这下我们的肌肉猛鸟又变成了一个大秤砣。那这个问题如何解决呢?那就是将抛物线恢复成原来的样子。

当一个物体在做斜抛或者平抛运动时,他的初速度决定了抛物线的形状,其中高度和垂直方向的初速度决定了重力的作用时间,水平方向的初速度决定了水平方向的位移。当速度降低时,重力作用的时间和水平方向上的位移都会随之变化,导致轨迹和之前大不相同。我们知道水平方向的位移:$x = v_{水平}t$,而垂直方向上的位移: $y = v_{垂直}t - \frac{1}{2}gt^2$,其中把 t 替换掉,就可以得到抛物线方程,$y = \frac{v_{垂直}}{v_{水平}}x - \frac{1}{2}\frac{g}{v_{水平}2$,其中 $\frac{v_{垂直}}{v_{水平}}$ 是物体发射的角度决定的,是一个固定值,那么影响曲线的就是 $\frac{g}{v_{水平}^2}$, 由此我们可以知道重力加速度的转换公式为 $g_b = \frac{g_{normal}}{\Delta s^2}$ ,但是事实情况是这样吗?并不是,因为我们减少速度的时候,我们的场景其实也在缩小,相同时间下原本300米的距离缩短到了25米,所以 y 和 x 需要有一个同样的缩放比例才能得到一样形状的抛物线。那么我们可以得出结论,其实真正的重力加速度转换公式应该为$g_b = \frac{g_{normal}}{\Delta s}$(此处略去一些联立的无聊等式)。

不懂物理的前端不是好的游戏开发者(二)—— 物理引擎的学习之路-LMLPHP

游戏中的合力

力累加器

接触过简单的力学的都知道,当多个力作用在一个物体上的时候,我们可以通过合力与分力,将复杂的多种力的结合处理为我们方便计算的几个方向上的力。

首先我们明确一点,合力是多个力在矢量层面的叠加,那么就需要用到矢量的加减法。其中最常用的是平行四边形法则,而平行四边形法则在坐标轴里的处理十分的简单,就是将两个矢量的坐标相加即可。但是力本身并不一定是恒力,比如说空气阻力,可能随着速度的增加而增加;浮力,随着进入水中体积的增加而增加。所以我们需要时刻计算合力,那么在游戏中,就是在每一帧的时候都进行一次计算。

不懂物理的前端不是好的游戏开发者(二)—— 物理引擎的学习之路-LMLPHP

我们为此可以创建一个类,专门用于合力,称其为力累加器。累加器的概念在面向对象编程和函数式编程里面使用的比较多,在 js 里面也有对应的运行方法—— Array.reduce。它可以直接将数组中的元素进行累计,此处的累计不仅仅代表累加,可以是任何操作。那么事情就变得简单起来,我们只需要将力都汇总在一个数组中,并且给一个累加的函数即可。我们以二维平面为例来看一下:

class ForceAccum () {
    constructor () { // 初始化力的数组
        this.forceArray = []
    }

    addForce(force) { // 添加力到数组中
        this.forceArray.push(force)
    }

    clearForceAccum () { // 清除数组用于下一次计算
        this.forceArray = []
    }

    forceReducer (prevForce, currentForce) { // 计算合力
        const x = prevForce.x + currentForce.x
        const y = prevForce.y + currentForce.y
        return {x, y}
    }

    getForce () { //获取合力
        const force = this.forceArray.reduce(forceReducer)
        return force
    }
}

const forceAccum = new ForceAccum() //新建一个力累加器
forrceAccum.addForce(重力) // 增加重力
forrceAccum.addForce(阻力) // 增加阻力
forrceAccum.addForce(推力) // 增加推力
const force = forrceAccum.getForce() //计算合力
forrceAccum.clearForceAccum() // 清除数组用于下次计算

以上就是最简单的力累加器和其应用,往系统中增加力,最后获取合力。获取到合力之后我们便可以根据牛二定律得到加速度,最后根据加速度积分得到速度,然后根据速度积分得到位置。然后我们每一帧都重复以上操作即可。

而我们在《愤怒的小鸟》里面需要什么力呢?可以看出需要的是一个弹弓的弹力导致的推力以及自身的重力,那么我们只需要将这两个力加到我们的力累加器中即可计算出我们的肌肉猛鸟受到的力以及在水平和垂直方向上的分力。

不懂物理的前端不是好的游戏开发者(二)—— 物理引擎的学习之路-LMLPHP

而我们在实际的运动中,其实只有一个重力在起作用(不计空气阻力),推力在弹弓的弹性形变中最后转换成了飞行的初速度。所以如果为了简化计算,可以直接将弹簧的推力转换成初速度。当然也可以通过冲量和动量进行计算了。

那这样的每个力我们应该如何在代码中得到然后计算呢?这就需要用到另外一个东西——力发生器。

力发生器

力发生器,顾名思义,创造力的装置。因为我们在运动中,存在着各种各样的力,有的力是恒定的,例如质量不变时的重力;有些力是根据场景变化的,例如速度不断变化时的空气阻力;而有些力是根据玩家操作来变化的,比如说推力,弹力等。

那么其实我们可以根据这些力的特性,为它们注册对应的力发生器,这样可以更好的管理它们。而力发生器的原理非常简单,我们通过一个类来进行创建。但是各个力的特性不同,所以我们需要针对不同的力进行处理,这里有两种不同的方法,一种是将所有需要包括的力都写在一个类中,需要创建力的时候,使用对应的方法;另一种则是将生成力的方法和参数传入一个类中,最后返回需要的力,或者直接将力注入到力累加器中。后者的灵活性会使得我们在复杂的力学系统中更好的控制我们的系统。

class ForceRegister () {
    constructor (forceAccum,func, param) {
        this.forceAccum = forceAccum
        this.func = func
        this.param = param
        this.force = null
    }

    createForce () { // 返回需要的力
        this.force = this.func(this.param)
        return this.force
    }

    addForce () { // 直接将力注入力累加器
        this.createForce()
        forrceAccum.addForce(this.force)
    }
}

上述代码中的 func,param 就是我们需要生成的力的方法和参数。例如重力,重力只需要输入物体质量(或者质量的倒数)和重力加速度,就可以得到对应的力 —— { x:0, y: mg }。阻力也是同理,阻力方程为 $\displaystyle F_{D},=,{\tfrac {1}{2}},\rho ,v^{2},C_{D},A$。其中的参数我们先不去细究,简化一下可以得到 $F_{D} = av^2$,a 为某个条件下的参数。这样的话我们阻力生成器的参数就是 a 和 速度 v,然后方向是运动速度的反方向。

有了力生成器和力累加器,我们可以方便地管理游戏中的力学体系。但是在游戏中每帧都需要大量的计算和刷新,对于性能的要求肯定是比较高的。所以便有了各种各样的优化方式。

比如说我们可以去掉空气阻力,水流阻力等以节省繁杂的计算,或者只给一个固定值来进行计算。对于比较重要的重力,我们可以通过内建重力的方式,直接将重力加速度存入整个物理体系,而不是将重力纳入到每个物体的每次计算当中。

那么其实我们明白了,我们只需要在系统中设置重力加速度,并且根据用户操作设置好初速度的向量,就可以快速完成一个小鸟的抛物运动了。

总结

通过简单的力学知识再加上合适的代码,我们就可以创建出一个符合力学规律的超级简易版《愤怒的小鸟》世界了。但是这其实是远远不够的,一个游戏中除了力的简单叠加和位移,还有力矩、碰撞、旋转、角速度等等,只有加上了这些,我们才能去计算碰撞,得分,去合理的表现物体被撞后的受力、旋转、移动。这些有趣的内容请期待一下我们物理引擎系列下面的章节~

欢迎关注凹凸实验室博客:aotu.io

或者关注凹凸实验室公众号(AOTULabs),不定时推送文章:

不懂物理的前端不是好的游戏开发者(二)—— 物理引擎的学习之路-LMLPHP

03-17 19:50