6

pic0.jpg

示例代码托管在:http://www.github.com/dashnowords/blogs

博客园地址:《大史住在大前端》原创博文目录

华为云社区地址:【你要的前端打怪升级指南】

[TOC]

Animation.gif


111.gif

在前端开发领域,物理引擎是一个相对小众的话题,它通常都是作为游戏开发引擎的附属工具而出现的,独立的功能演示作品常常给人好玩但是无处可用的感觉。仿真就是在计算机的虚拟世界中模拟物体在真实世界的表现(动力学仿真最为常见)。仿真能让画面中物体的运动表现更符合玩家对现实世界的认知,比如在《愤怒的小鸟》游戏中被弹弓发射出去小鸟或是因为被撞击而坍塌的物体堆,还有在《割绳子》小游戏中割断绳子后物体所发生的单摆或是坠落运动,都和现实世界的表现近乎相同,游戏体验通常也会更好。

用物理引擎可以帮助开发者更快速地实现诸如碰撞反弹、摩擦力、单摆、弹簧、布料等等不同类型的仿真效果。物理引擎通常并不需要处理和画面渲染相关的事务,而只需要完成计算仿真的部分就可以了,你可以把它理解成MVC模型中的M层,它和用于渲染画面的V层理论上是独立。matter.js提供了基于canvas2D API的渲染引擎,p2.js在示例代码中提供了一个基于WebGL实现的渲染器,在开发社区也可以找到p2.js与CreateJS或Egret联合使用的示例。游戏引擎和物理引擎的联合使用并没有想象中那么复杂,实际上只需要完成不同引擎之间的坐标系映射就可以了,熟练地开发者可能会喜欢这种“低耦合”带来的灵活性,但对于初级开发者而言无疑又提高了使用门槛。

一.经典力学回顾

经典力学的基本定律就是牛顿三大运动定律或与其相关的力学原理,它可以用来描述宏观世界低速状态下的物体运动规律,也为游戏开发中的物理仿真提供了计算依据,大多数仿真都是基于经典力学的公式或其简化形式进行计算模拟的,使用率较高的公式定律包括:

  • 牛顿第一定律

    牛顿第一定律又称惯性定律,它指出任何物体都要保持匀速直线运动或静止状态,直到外力迫使它改变运动状态为止。

  • 牛顿第二定律

    牛顿第二定律是指物体的加速度与它所受的外力成正比,与物体的质量成反比,加速度的方向与物体所受合外力速度相同,它可以模拟物体加速减速的过程,计算公式为(F为合外力,m为物体质量,a为加速度):

f1.png

  • 动量守恒定理

    如果一个系统不受外力或所受外力的矢量和为零,那么这个系统的总动量保持不变,动量即质量m和速度v的乘积,它通常被用于模拟两物体碰撞,动量守恒定律的计算公式可以由牛顿第二定律推导得出(F为合外力,t为作用时长,m为物体质量,v2为末速度,v1为初速度):

f2.png

  • 动能定理
    合外力对物体所做的功,等于物体动能的变化量,公式表达如下(W为合外力做功,m为物体质量,v2为末速度,v1为初速度):

f3.png

当合外力为一个恒定的力时,它所做的功可以通过如下公式进行计算(W为合外力做功,F为合外力大小,S为物体运动的距离):

f4.png

  • 胡克定律

    胡克定律指出当弹簧发生弹性形变时,弹簧的弹力F和其伸长量(或压缩量)x成正比,它是物理仿真中进行弹性相关计算的主要依据,相关公式如下(F表示弹力,k表示弹性系数,x表示弹簧长度和无弹力时的长度差):

f5.png

利用经典力学的相关原理,就可以在计算机中模拟物体的物理特性,对于匀速圆周运动、单摆、电磁场等的模拟都可以依据相关的物理原理进行仿真,本节中不再展开。

二. 仿真的实现原理

2.1 基本动力学模拟

Canvas动画是一个逐帧绘制的过程,物理引擎作用的原理就是为抽象实体增加物理属性,在每一帧中更新它们的值并计算这些物理量造成的影响,然后再执行绘制的命令。对物体进行动力学模拟时需要使用到质量、合外力、速度、加速度等属性,其中质量是标量值(即没有方向的值),而合外力、速度、加速度都是矢量值(有方向的值)。无论在2D还是3D图形学计算中,向量计算的频率都是极高的,如果不进行封装,代码中可能就会充斥着大量底层数学计算代码,影响代码的可读性,为了方便计算,我们先将二维向量的常见操作封装起来:

/*二维向量类定义*/
class Vector2{
    constructor(x, y){
        this.x = x;
        this.y = y;
    }
    copy() {
        return new Vector2(this.x, this.y); 
    }
    length() { 
        return Math.sqrt(this.x * this.x + this.y * this.y);
    }
    sqrLength() { 
        return this.x * this.x + this.y * this.y; 
    }
    normalize:() { 
        var inv = 1 / this.length(); 
        return new Vector2(this.x * inv, this.y * inv);
    }
    negate() { 
        return new Vector2(-this.x, -this.y); 
    }
    add(v) { 
        return new Vector2(this.x + v.x, this.y + v.y); 
    }
    subtract(v) { 
        return new Vector2(this.x - v.x, this.y - v.y); 
    }
    multiply(f) { 
        return new Vector2(this.x * f, this.y * f); 
    }
    divide(f) { 
        var invf = 1 / f; 
        return new Vector2(this.x * invf, this.y * invf);
    }
    dot(v) { 
        return this.x * v.x + this.y * v.y; 
    }
}

为了让物体实例都拥有仿真必要的属性结构,可以定义一个抽象类,再用物体的类去继承它就可以了,这和你平时编写React应用时用自定义类继承React.Component是一样的,伪代码示例如下:

class AbstractSprite{
    constructor(){
            this.mass = 1; //物体质量
            this.velocity = new Vector2(0, 0);//速度
            this.force = new Vector2(0, 0);//合外力
            this.position = new Vector2(0, 0);//物体的初始位置
            this.rotate = 0; //物体相对于自己对称中心的旋转角度
    }
}

我们并没有在其中添加加速度属性,使用合外力和质量就可以计算出它,position属性用来确定对象绘制的位置,rotate属性用来确定对象的偏转角度,上面列举的属性在计算常见的线性运动场景中就足够了。事实上属性的取舍并没有统一的标准,比如要模拟天体运动,可能还需要添加自转角速度、公转角速度等,如果要模拟弹簧,可能就需要添加弹性系数、平衡长度等,如果要模拟台球滚动时的表现,就需要添加摩擦力,所选取的属性通常都是直接或间接影响物体在画布上最终可见形态的,你可以在子类中声明这些特定场景中才会使用到的属性。声明一个新的物体类的示例代码如下:

class AirPlane extends AbstractSprite{
    constructor(props){
        super(props);
        /* 声明一些子属性 */
        this.someProp = props.someProps;
    }
    /* 定义如何更新参数 */
    update(){}
    /* 定义如何绘制 */
    paint(){}
}

上面的模板代码相信你已经非常熟悉了,状态属性的更新代码编写在update函数中即可,更新函数理论上的执行间隔大约是16.7ms,计算过程中可以近似认为属性是不变的。我们知道加速度在时间维度的积累影响了速度,速度在时间上的积累影响位移:

f6.png

仿真中过程中的Δt是自定义的,你可以根据期望的视觉效果去调整它,Δt越大,同样大小的物理量在每一帧中造成的可见影响就越显著,更新时使用向量计算来进行:

this.velocity = this.velocity.add(this.force.divide(this.mass).multiply(t));
this.position = this.position.add(this.velocity.multiply(t));

运动仿真中需要对那些体积较小但速度较快的物体多加留意,因为基于包围盒的检测很可能会失效,例如在粒子仿真相关的场景中,粒子是基于引力作用而运动的,初始距离较远的粒子在相互靠近的过程中速度是越来越快的,这就可能使得在连续的两帧计算中,两个粒子的包围盒都没有重叠,但实际上它们已经发生过碰撞了,而计算机仿真中就会因为逐帧动画的离散性而错过碰撞的画面,这时两个粒子又会开始做减速运动而相互远离,整体的运动状态就呈现为简谐振动的形式。所以在针对粒子系统的碰撞检测时,除了包围盒以外,通常还会结合速度和加速度的数值和方向变化来进行综合判定。

2.2 碰撞模拟

碰撞,是指两个或两个物体在运动中相互靠近或发生接触时,在较短的时间内发生强相互作用的过程,它通常都会造成物体运动状态的变化。碰撞模拟一般使用完全弹性碰撞来进行计算,它是一种假定碰撞过程中不发生能量损失的理想状况,这样的碰撞过程就可以利用动量守恒定律和动能守恒定律进行计算:

f7.png
公式中只有V1’和V2’是未知量,联立方程就可以求得碰撞后速度的计算公式:
f8.png

在引擎检测到碰撞发生时只需要根据公式来计算碰撞后的速度就可以了,可以看到公式中使用到的属性都已经在抽象物体类中进行了声明,需要注意的是速度合成需要进行矢量运算。完全弹性碰撞只是为了方便计算的假设情况,大多数情况下我们并不需要知道碰撞造成的能量损失的确切数值,所以如果想要模拟碰撞造成的能量损失,可以在每次碰撞后将系统的总动能乘以0~1之间的系数来达到目的。

pic1.png

另一种典型的场景是物体之间发生非对心碰撞,也就是物体运动方向的延长线并不经过另一个物体的质心,运动模拟时为了简化计算通常会忽略物体因碰撞造成的旋转,将物体的速度先分解为指向另一物体质心方向的分量和垂直于该连线的分量,接着使用弹性对心碰撞的公式来求解对心碰撞的部分,最后再将碰撞后的速度与之前的垂直分量进行合成得到碰撞后的速度。

你不必担心物理仿真中繁琐的计算细节,大多数常用的场景都可以使用物理引擎快速实现,学习原理并不是为了重复去制造一些简陋的“轮子”,而是让你在面对引擎不适用的场景时可以自己去实现相应的开发。

三. 物理引擎matter.js

3.1 《愤怒的小鸟》的物理特性分析

《愤怒的小鸟》是一款物理元素非常丰富的游戏,本节中以此为例进行一个简易的练习。游戏中首先需要实现一个模拟的地面,否则所有物体就会直接坠落到画布以外,接着需要制作一个弹弓,当玩家在弹弓上按下鼠标并向左拖动时,弹弓皮筋就会被拉长,且中间部位就会出现一只即将被弹射出去的小鸟。当玩家松开鼠标时,弹弓皮筋由于拉长而积蓄的弹性势能会逐渐转变成小鸟的动能,从而将小鸟发射出去,这时小鸟的初速度是向斜上方的,在后续的运动过程中会因为受到重力和空气阻力的影响而逐渐改变,重力垂直向下且大小不变,而空气阻力与合速度方向相反,整个飞行过程中就需要在每一帧中更新小鸟的速度。画面的右侧通常是一个由各种各样不同材质的物体布景和绿色的猪头组成的静态物体堆,当小鸟撞击到物体堆后,物体堆会发生坍塌,物体堆的各个组成部分都会遵循物理定律的约束而改版状态,从而呈现出仿真的效果,坍塌的物体堆压到绿色猪头后会将其消灭,当所有的猪头都被消灭后,就可以进入下一关了。
pic2.jpg
我们先使用matter.js为整个场景建立物理模型,然后再使用CreateJS建立渲染模型,通过坐标和角度同步来为各个物理模型添加静态或动态的贴图。为了降低建模的难度,本节的示例中将弹弓皮筋的模型简化为一个弹簧,只要可以将小鸟弹射出去即可。

3.2 使用matter.js 构建物理模型

matter.js的官方网站提供的示例代码如下,它可以帮助开发者熟悉基本概念和开发流程,你可以在【官方代码仓】中找到更多示例代码:

var Engine = Matter.Engine,
     Render = Matter.Render,
   World = Matter.World,
     Bodies = Matter.Bodies;

// create an engine
var engine = Engine.create();

// create a renderer
var render = Render.create({
    element: document.body,
    engine: engine
});

// create two boxes and a ground
var boxA = Bodies.rectangle(400, 200, 80, 80);
var boxB = Bodies.rectangle(450, 50, 80, 80);
var ground = Bodies.rectangle(400, 610, 810, 60, { isStatic: true });

// add all of the bodies to the world
World.add(engine.world, [boxA, boxB, ground]);

// run the engine
Engine.run(engine);

// run the renderer
Render.run(render);

示例代码中使用到的主要概念包括负责物理计算的Engine(引擎)、负责渲染画面的Render(渲染器)、负责管理对象的World(世界)以及用于刚体绘制的Bodies(物体),当然这只是matter.js的基本功能。Matter.Render通过改变传入的参数,就可以在画面中标记处物体的速度、加速度、方向及其他调试信息,也可以直接将物体渲染为线框模型,它在调试环境或一些简单场景中非常易用,但面对诸如精灵动画管理等更为复杂的需求时,就需要对其进行手动扩展或是直接替换渲染器。

在《愤怒的小鸟》物理建模过程中,static属性设置为true的刚体都默认拥有无限大的质量,这类刚体不参与碰撞计算,只会将碰到它们的物体反弹回去,如果你不想让世界中的物体飞出画布的边界,只需要在画布的4个边分别添加静态刚体就可以了。物体堆的建立也非常容易,常用的矩形、圆、多边形等轮廓都可以使用Bodies对象直接创建,位置坐标默认的参考点是物体的中心。当世界中的物体初始位置已经发生区域重叠时,引擎就会在工作时直接依据碰撞来处理,这可能就会导致一些物体拥有意料之外的初速度,在调试过程中,可以通过激活刚体模型的isStatic属性来将其声明为静态刚体,静态刚体就会停留在自己的位置上而不会因为碰撞检测的关系发生运动,这样就可以对模型的初始状态进行检测了,如下图所示:

pic3.png

构建弹簧模型的技术被称为“约束”,相关的方法保存在约束模块Matter.Constraint上。单独存在的约束并没有什么实际意义,它需要关联两个物体,用来表示被关联物体之间的约束关系,如果只关联了一个物体,则表示这个物体和固定锚点坐标之间的约束关系,固定坐标默认为(0,0),可以通过pointA或pointB属性调整固定锚点的位置,《愤怒的小鸟》中使用的弹簧模型就是后一种单端固定的形式。我们只需要找到小鸟被弹射出去时经过弹弓横切面的位置,建立一个包含坐标值的对象作为锚点,然后再建立一个动态刚体B作为鼠标拉动弹簧时小鸟图案的附着点,最后在这两个对象之间创建约束就可以了,创建约束时需要声明弹性系数stiffness,它表明了约束发生形变的难易程度。这个示例中约束两端的平衡位置是重合在一起的,当玩家使用鼠标拖动小鸟图案附着点离开平衡位置后,就可以看到画面上渲染出的两点之间的弹簧约束,当用户松开鼠标后,弹簧就收缩,附着点就会回到初始位置,回弹的过程是一个类似于阻尼振动的过程,约束的弹性系数越大,端点回弹时在平衡位置波动就越小。当需要模拟弹簧被压缩时,就需要通过length属性来定义约束的平衡距离,约束复原时就会恢复到这个平衡距离。示例代码如下:

birdOptions = { mass: 10 },
bird = Matter.Bodies.circle(200, 340, 16, birdOptions),
anchor = { x: 200, y: 340 },
elastic = Matter.Constraint.create({
            pointA: anchor,
            bodyB: bird,
            length: 0.01, 
            stiffness: 0.25
        });

鼠标模块Matter.Mouse和鼠标约束模块Matter.MouseConstraint提供了鼠标事件跟踪与用户交互相关的能力,配合Matter.Events模块就可以对鼠标的移动、点击、物体拖拽等典型事件进行监听,使用方式相对固定,你只需要浏览一下官方文档,熟悉一下引擎支持的事件就可以了,相关示例代码如下:

//创建鼠标对象
var mouse = Mouse.create(render.canvas);

//创建鼠标约束
Var mouseConstraint = MouseConstraint.create(engine, {
            mouse: mouse,
            constraint: {
                stiffness: 0.2,
                render: {
                    visible: false
                }
            }
        });

 //监听全局鼠标拖拽事件
Events.on(mouseConstraint, 'startdrag', function(event){
    console.log(event);
})

物理引擎的更新也是逐帧进行的,可以利用Matter.Events模块来监听引擎发出的事件,以每次更新计算后发出的afterUpdate事件为例,在回调函数中判断是否需要将小鸟弹射出去。弹射是在玩家使用鼠标向画面左下方拖动并松开鼠标后发生的,我们可以依据小鸟附着点的位置进行弹射判定,当小鸟处于锚点右上侧并超过一定距离时,将其判定为可发射,发射的逻辑是生成一个新的小鸟附着点,将原约束中的bodyB进行替换,原本的附着点在约束解除后就表现为具有一定初速度的抛物运动,飞向物体堆。示例代码如下:

const ejectDistance = 4; //定义弹射判断的位移阈值
const anchorX = 200; //定义弹簧锚点的x坐标
const anchorY = 350; //定义弹簧锚点的y坐标

//每轮更新后判断是否需要更新约束
Events.on(engine, 'afterUpdate', function () {
     if (mouseConstraint.mouse.button === -1 
&& bird.position.x > (anchorX + ejectDistance) 
&& bird.position.y < (anchorY - ejectDistance)) {
              bird = Bodies.circle(anchorX, anchorY, 16, birdOptions);
              World.add(engine.world, bird);
              elastic.bodyB = bird;
        }
    });

需要注意的是matter.js构建的刚体模型会以物体几何中心作为定位参考点的。至此,简易的物理模型就构建好了,线框图效果如下所示:

pic4.png

尽管看起来有些简陋,但它已经可以模拟很多物理特性了,下一小节我们为模型进行贴图后,它就会看起来就比较像游戏了,物理模型的完整代码可以在我的代码仓库中获取到。

3.3 物理引擎牵手游戏引擎

matter.js提供的渲染器模块Matter.Render非常适合物理模型的调试,但在面对游戏制作时还不够强大,比如原生Render模块为模型贴图时仅支持静态图片,而游戏中则往往会大量使用精灵动画来增加趣味性,这时将物理引擎和游戏引擎联合起来使用就是非常好的选择。

当你将Matter.Render相关的代码都删除后,页面上就不再绘制图案了,但是如果你在控制台输出一些信息的话,就会发现示例中监听afterUpdate事件的监听器函数仍然在不断执行,这就意味着物理引擎仍然在持续工作,不断刷新着模型的物理属性数值,只是没有将画面渲染到画布上而已。渲染的工作,自然就要交给渲染引擎来处理,当使用CreateJS来开发游戏时,渲染引擎使用的就是Easel.js。首先,使用Easel.js对所有保存在物理空间engine.world.bodies数组中的模型建立对应的视图模型,所谓视图模型,就是指物体的可见外观,比如一个长方形,可能代表木头,也可能代表石块,这取决于你使用什么样的贴图来表示它,视图模型可以是精灵表、位图或是自定义图形等任何Easel.js支持的图形,建立后将它们依次添加到舞台实例stage中。这样每个物体实际上有两个模型与之对应,物理空间中的模型依靠物理引擎更新,负责在每一帧中为对应物体提供位置坐标和旋转角度,并确保变化趋势符合物理定律;渲染舞台中的模型保存着物体的外观样式,依靠渲染引擎来更新和绘制,你只需要在每一帧更新物体属性时将物理模型的关键信息(通常是位置坐标和旋转角度)同步给渲染模型就可以了。基本的逻辑流程如下所示:

pic5.png

按照上面的流程扩展之前的代码并不困难,完成后的游戏画面看起来有趣多了:

pic6.png
完整的代码已上传至代码仓库。相信你已经发现,最终画面里的物体布局和物理引擎中的布局是一样的,物理引擎的本质,就是为每个渲染模型提供正确的坐标和角度,并保证这些数据在逐帧更新过程中的变化和相互影响符合物理定律。如果第三方物理引擎无法满足你的需求,那么动手去实现自己的引擎吧,相信你已经知道该如何开始了


大史不说话
81 声望27 粉丝

字节跳动前端 | 《前端跨界开发指南》作者