一、基础概念
参考
拉小登《Box2D物理游戏编程初学者指南》
拉小登博客 认识Box2D世界
拉小登博客 掉落的苹果——b2Body刚体
1.创建世界
首先认识一下Box2D世界——b2world。b2world是Box2D系统模拟物理世界的核心,可以想象成我们的地球。地球上有各种不同的物理环境,如水中漂浮的小船,太空中失重的宇航员等等。同样在Box2D世界中也要有一个环境,这在Box2D中用b2AABB类表示,如下:var b2AB:b2AABB = new b2AABB();
PS:在《Advanced game design with flash》中说过AABB是指Axis-Align-Bounding-Boxes.现实中的世界是无穷无尽的,但是我们看到的是有限的,所以Box2D中也要限制一下环境的大小,因为我们看到的是有限的嘛!(world的有效范围) 。我猜你可能跟我一样,提到到大小想到的是肯定width和height,而Box2D中的大小是通过设置两个对角顶点upperBound和lowerBound实现的
b2AB.lowerBound.Set( -100, -100);
b2AB.upperBound.Set( 100, 100);
环境设置好了,重力也是少不了的,否则我们就都飘在空中了。同样,Box2D世界中也少不了:var gravity:b2Vec2 = new b2Vec2(0, 10);
这个坐标和Flash一样,X轴向右,Y轴向下。
任何运动物体,因为摩擦力、能量转换能因素,最终会慢慢停止运动。Box2D的刚体也是一样,对于静止不动的物体,设置参数doSleep:Boolean = true;相当于标记为睡眠状态,在遍历时会直接跳过,不进去运动模拟计算,节省CPU开支。
这样,环境变量设置好后,就可以new出一个Box2D世界了。var world:b2World = new b2World(b2AB,gravity, doSleep);
说明:上述代码是基于2.0.1版本的,在2.1a之后去掉了b2AABB环境变量。具体参考拉小登博客 关于版本 从Box2D 2.0.1到Box2D 2.1a,后面遇到版本差异也可以参考此文档。
新版本创建世界使用如下代码:var world = new b2World(new b2Vec2(0,9.8),true);
2.计量单位是米
在Box2D中的计量单位是米m,而不是Flash中的像素px,在布置坐标时,要进行一个转换,1米=30个像素。所以Box2D中(a,b)点对于Flash中的(a*30,b*30)的位置,或者说Flash中的(c,d)位置对应Box2D中的(c/30,d/30)位置。
3.Box2D用b2DebugDraw进行模拟调试
Box2D是一个物理引擎,不会向Flash显示列表中添加任何显示对象。不过Box2D中有一个b2DebugDraw类,可以绑定一个显示对象,进行模拟调试。
- 创建一个空的Sprite对象debugSprite,并添加到舞台中
- 创建一个b2DebugDraw对象debugDraw,并设置它的m_sprite属性值为debugSprite.
- 用world的SetDebugDraw方法,绑定debugDraw
var debugDraw = new b2DebugDraw();
debugDraw.SetSprite(document.getElementById("canvas").getContext("2d"));
debugDraw.SetDrawScale(30.0);
debugDraw.SetFillAlpha(0.3);
debugDraw.SetLineThickness(1.0);
debugDraw.SetFlags(b2DebugDraw.e_shapeBit | b2DebugDraw.e_jointBit);
world.SetDebugDraw(debugDraw);
4.World的更新:
Box2D的和Flash一样,需要实时更新,Flash中有ENTER_FRAME,b2world有step:
window.setInterval(update, 1000 / 60);
function update() {
//Take a time step(2.1a版本)
world.Step(
1 / 60 //frame-rate
, 10 //velocity iterations速度迭代
, 10 //position iterations位置迭代
);
};
(1)这里更改帧频,可以模拟出慢镜头或快进效果,就像俄罗斯方块中按向下时,方块会加速落下一样。但是快镜头因为两次step间物体移动距离较远,容易出现物体瞬间移动,部分高速运动物体碰撞无法有效检测,出现穿透现象。在实际的开发过程中,应该避免delta>interval情况的出现,可以相应提高游戏帧频来实现快镜头播放效果。
(2)位置迭代
现实中两个物体发生碰撞时,为了缓解碰撞,物体会发生一些形变。Box2D中物体全是坚硬的刚体,无法产生形变,这时就会出现物体之间的重叠。此时,Box2D会调用b2ContactSovler类的SolvePositionConstraints()来矫正碰撞重叠。不过这个函数通常无法一次性完全消除碰撞重叠,position iterations指的就是单次step中SolvePositionConstraints()的执行次数,值 越大,精度 越高,消耗资源越多。
(3)速度迭代 和位置迭代类似
二、Laya封装
在游戏开发中,物理系统虽说不是每款必用,但它是提升游戏用户体验的重要因素之一,一些经典的物理游戏如:愤怒的小鸟,小鳄鱼顽皮爱洗澡等,都是用物理系统制作的丰富关卡,风靡全球,伴随着广大游戏开发者的需求以及引擎的迭代,Laya2.0集成了Box2D物理系统,对Box2D进行封装之后,使得开发者可以免去接入Box2D物理系统需要面对的种种困难,和使用不便。
1.在Main.ts中,调用Laya["Physics"] && Laya["Physics"].enable();
//Physics.as
/**2D游戏默认单位为像素,物理默认单位为米,
此值设置了像素和米的转换比率,默认50像素=1米*/
public static var PIXEL_RATIO:int = 50;
/**
* 开启物理世界
* options值参考如下:
allowSleeping:true,
gravity:10,
customUpdate:false 自己控制物理更新时机,自己调用Physics.update
*/
public static function enable(options:Object = null):void {
I.start(options);
}
/**
* 开启物理世界
* options值参考如下:
allowSleeping:true,
gravity:10,
customUpdate:false 自己控制物理更新时机,自己调用Physics.update
*/
public function start(options:Object = null):void {
if (!_enabled) {
_enabled = true;
options || (options = {});
var box2d:* = window.box2d;
if (box2d == null) {
console.error("Can not find box2d libs, you should reuqest box2d.js first.");
return;
}
var gravity:* = new box2d.b2Vec2(0, options.gravity || 500 / PIXEL_RATIO);
this.world = new box2d.b2World(gravity);
this.world.SetContactListener(new ContactListener());
this.allowSleeping = options.allowSleeping == null ? true : options.allowSleeping;
if (!options.customUpdate) Laya.physicsTimer.frameLoop(1, this, _update);
_emptyBody = _createBody(new window.box2d.b2BodyDef());
}
}
private function _update():void {
world.Step(1 / 60, velocityIterations, positionIterations, 3);
var len:int = _eventList.length;
if (len > 0) {
for (var i:int = 0; i < len; i += 2) {
_sendEvent(_eventList[i], _eventList[i + 1]);
}
_eventList.length = 0;
}
}
/**
* 设置是否允许休眠,休眠可以提高稳定性和性能,但通常会牺牲准确性
*/
public function get allowSleeping():Boolean {
return this.world.GetAllowSleeping();
}
public function set allowSleeping(value:Boolean):void {
this.world.SetAllowSleeping(value);
}
从代码里可以看到一些默认设定,比如PIXEL_RATIO=50,重力设定是(0,10),帧率60,速度迭代velocityIterations:int = 8;位置迭代positionIterations:int = 3;
这里想在start中的options设定零重力也就是漂浮状态就尴尬了,因为var gravity:* = new box2d.b2Vec2(0, options.gravity || 500 / PIXEL_RATIO);
当然可以初始化后重新设定:Laya.Physics.I.gravity = {x:0,y:0};
/**
* 物理世界重力环境,默认值为{x:0,y:1}
* 如果修改y方向重力方向向上,可以直接设置gravity.y=-1;
*/
public function get gravity():Object {
return this.world.GetGravity();
}
public function set gravity(value:Object):void {
this.world.SetGravity(value);
}
在Main.ts中使用了if (GameConfig.physicsDebug && Laya["PhysicsDebugDraw"]) Laya["PhysicsDebugDraw"].enable();
开启物理辅助线,在PhysicsDebugDraw.as中可以看到:
public static function enable(flags:int = 99):PhysicsDebugDraw {
if (!I) {
var debug:PhysicsDebugDraw = new PhysicsDebugDraw();
debug.world = Physics.I.world;
debug.world.SetDebugDraw(debug);
debug.zOrder = 1000;
debug.m_drawFlags = flags;
Laya.stage.addChild(debug);
I = debug;
}
return debug;
}
这里的flags默认被设置成了99(1|2|32|64),这是一个十六进制整数,对应的选项有5个,可以通过或运算符来开启多个选项,对照b2DebugDraw的静态参数含义:
e_shapeBit:0x0001 即1显示刚体形状
e_jointBit:0x0002 即2显示关节
-
e_aabbBit:0x0004 即4显示包裹形状最小包围盒AABB矩形
e_pairBit:0x0008 即8显示潜在碰撞对象组合
-
e_centerOfMassBit:0x0010 即16显示刚体重心(红绿两道杠)
e_controllerBit 0x0020 即32Draw controllers
laya默认的99还使用了32和64,暂时没看出是什么用。
这里我需要看重心,所以传入1|2|16=
2.参考容器
/**物理世界根容器,将根据此容器作为物理世界坐标世界,进行坐标变换,默认值为stage
* 设置特定容器后,就可整体位移物理对象,保持物理世界不变*/
public function get worldRoot():Sprite {
return _worldRoot || Laya.stage;
}
public function set worldRoot(value:Sprite):void {
_worldRoot = value;
if (value) {
//TODO:
var p:Point = value.localToGlobal(Point.TEMP.setTo(0, 0));
world.ShiftOrigin({x: p.x / PIXEL_RATIO, y: p.y / PIXEL_RATIO});
}
}
3.刚体
LayaAir引擎中集成的Box2D物理系统,首先要了解刚体rigidbody 和碰撞体collider,当物体包含刚体的时候就可以收到物理引擎的影响,当物体包含碰撞体的时候物体可以发生碰撞,当物体含有碰撞体不含有刚体的时候可以被碰撞但不发生物理运动学动力影响。
- 刚体rigidbody :刚体是指在运动中和受力作用后,形状和大小不变,而且内部各点的相对位置不变的物体。
- 碰撞体collider:碰撞体是给物体加一个判定框,当碰撞框重叠的时候,两物体发生碰撞。
- 关节joint: 关节可以对两个或多个物体进行一种约束。
RigidBody类继承自 Component,刚体支持三种类型:static,dynamic和kinematic,默认为dynamic。
- static为静态刚体,静止不动,不受重力影响,质量无限大,可以通过节点移动,旋转,缩放进行控制;在模拟环境下静态物体是不会移动的,就好像有无限大的质量。在Box2D的内部会将质量至反,存储为零。静态物体可以被用户手动移动。静态物体有零速度。静态物体不能和其它静态或运动学物体进行碰撞。一般用在游戏中的地面,平台。
- dynamic为动态刚体,受重力影响;动态物体可以进行全模拟。它们可以被用户手动移动,但是通常情况下会根据受力进行移动。动态物体可以和任何物体发生碰撞。动态物体总是拥有有限的非零质量。如果你尝试设置动态物体的质量为零,它会自动设置一个1千克质量的物体。
- kinematic为可动刚体,不受重力影响,可以通过施加速度或者力的方式使其运动。运动学物体在模拟环境中根据自身的速度进行移动。运动学物体自身不受力的作用。虽然用户可以手动移动它,但是通常情况下我们会设置它的速度来进行移动。运动学物体的行为就像是有无限大的质量,尽管如此,在Box2D内部还是会对运动学物体的质量至反设置为零。运动学物体不能和其它静态或运动学物体进行碰撞。可以用来实现游戏中上下升降的电梯,移动的物体效果。
刚体的类型切换,会有一种暂停效果。从空中落下的刚体从动态切换到静态时,看起来就像是被瞬间冰冻住。
刚体的类型是强制性的,刚体组件如下图:
type
前文中提到三种类型:static,dynamic和kinematic,默认为dynamic。
gravityScale=1
重力缩放系数,默认为1,即正常重力,设置为0为没有重力。
angularVelocity
角速度,设置会导致旋转,单位为弧度,实际使用中需要约束。这里把type改为kinematic就可以看到一个方块绕着左上角在旋转。刚体默认是绕着质量中心Center of Mass也就是重心来旋转的,默认情况下重心就是刚体的坐标点。可以通过b2Body的SetMassData方法,改一下重心坐标,来模拟类似不倒翁效果。
angularDampin
旋转速度阻尼系数,范围从0到无穷大,0表示没有阻尼,无穷大表示满阻尼,通常阻尼的值应该在0到0.1之间。也可以叫旋转摩擦力,取值越大角速度降低越快。
linearVelocity
线性运动速度,需要输入向量,比如10,10,代表x轴向右速度10,y轴向下速度10。
linearDamping
线性速度阻尼系数,范围从0到无穷大,0表示没有阻尼,无穷大表示满阻尼,通常阻尼的值应该在0到0.1之间。在正常的重力环境下,刚体会因最终落至地面而停止运动,用到此属性较少。在重力为0的太空类游戏中,在没有发生碰撞的情况下,刚体会一直匀速运动,这时可以通过设置此属性来模拟地面摩擦力或空气阻力,来让刚体尽快停下来。
bullet
是否高速移动的物体,设置为true。这个属性是专门为了防止高速运动物理碰撞时,容易出现穿透现象而设立的,不过它也相对需要更多的运算开支。所以在游戏开发中,开启bullet属性的刚体不宜太多。bullet属性只对Type=dynamic的动态刚体有效,静态刚体和可动刚体默认都是按照bullet=true进行连续碰撞检测的,所以高速刚体可以穿透障碍物,但不会穿过四周的静态刚体。
allowSleep
是否允许休眠,允许休眠能提高性能,这个一般都要设置为true。
allowRotation
是否允许旋转,如果不希望刚体旋转,这设置为false。这是一个非常实用的属性,在横版类游戏中,游戏人物通常是一直保持直立状态,在碰撞发生时,不需要模拟因碰撞发生角度的变化,就可以用此属性来固定角度。
group=0
指定了该主体所属的碰撞组,默认为0.
碰撞规则如下:
1.如果两个对象group相等且
group值大于零,它们将始终发生碰撞
group值小于零,它们将永远不会发生碰撞
group值等于0,则使用规则3
2.如果group值不相等,则使用规则3
3.每个刚体都有一个category类别,此属性接收位字段,范围为[1,2^31]范围内的2的幂
每个刚体也都有一个mask类别,指定与其碰撞的类别值之和(值是所有category按位AND的值)
category=1
碰撞类别,使用2的幂次方值指定,有32种不同的碰撞类别可用。
mask=-1
指定冲突位掩码碰撞的类别,category位操作的结果。
label="RigidBody"
自定义标签
更多属性可以参考拉小登的《Box2D物理游戏编程初学者指南》,有个演示的swf
4.刚体流程分析
以官方示例来看,一张图片,挂载了RigidBody组件后,对应的json结构是这样的:
{
"type":"Scene",
"props":{ "width":1136, "height":640 },
"compId":2,
"child":[
{
"type": "Sprite",
"props": { "y": 168, "x": 390, "texture": "test/block.png" },
"compId": 3,
"child": [
{
"type": "Script",
"props": {
"width": 20,
"height": 20,
"runtime": "laya.physics.BoxCollider"
},
"compId": 5
},
{
"type": "Script",
"props":
{
"type": "kinematic",
"runtime": "laya.physics.RigidBody"
},
"compId": 6
}
],
"components": []
},
参考laya2.0的场景Scene和脚本Script的结论:创建页面时,会激活Node,然后调用comp._setActive(true)
,最终调用到组件Component类的_onAwake方法。
那么RigidBody作为Component的子类,_onAwake方法做了什么?
override protected function _onAwake():void {
this._createBody();
}
private function _createBody():void {
if (_body) return;
var sp:Sprite = owner as Sprite;
var box2d:* = window.box2d;
var def:* = new box2d.b2BodyDef();
var point:Point = Sprite(owner).localToGlobal(Point.TEMP.setTo(0, 0), false, Physics.I.worldRoot);
def.position.Set(point.x / Physics.PIXEL_RATIO, point.y / Physics.PIXEL_RATIO);
def.angle = Utils.toRadian(sp.rotation);
def.allowSleep = _allowSleep;
def.angularDamping = _angularDamping;
def.angularVelocity = _angularVelocity;
def.bullet = _bullet;
def.fixedRotation = !_allowRotation;
def.gravityScale = _gravityScale;
def.linearDamping = _linearDamping;
var obj:Object = _linearVelocity;
if (obj && obj.x != 0 || obj.y != 0) {
def.linearVelocity = new box2d.b2Vec2(obj.x, obj.y);
}
def.type = box2d.b2BodyType["b2_" + _type + "Body"];
//def.userData = label;
_body = Physics.I._createBody(def);
//trace(body);
//查找碰撞体
var comps:Array = owner.getComponents(ColliderBase);
if (comps) {
for (var i:int = 0, n:int = comps.length; i < n; i++) {
var collider:ColliderBase = comps[i];
collider.rigidBody = this;
collider.refresh();
}
}
}
对照下面原生JS创建一个矩形刚体,很容易就明白。刚体的属性太多,所以分给b2BodyDef和b2FixtureDef这两个辅助类,但是laya中的RigidBody只有b2BodyDef,b2FixtureDef则被 放到了ColiderBase中了,在系列后续的碰撞体中会做介绍。
//定义一个材质
var fixDef = new b2FixtureDef;
fixDef.density = 1.0;//密度
fixDef.friction = 0.5;//摩擦
fixDef.restitution = 0.2;//弹性,越大越硬
fixDef.shape = new b2PolygonShape;//矩形
fixDef.shape.SetAsBox(5, 0.5);//宽高
//创建一个矩形地板刚体
var bodyDef = new b2BodyDef;
bodyDef.type = b2Body.b2_staticBody;//静态的
bodyDef.position.x = 10; //X轴
bodyDef.position.y = 13; //Y轴
//世界中添加一个刚体
world.CreateBody(bodyDef).CreateFixture(fixDef);
和_onAwake类似,_onEnable也会在创建后触发。
override protected function _onEnable():void {
this._createBody();
//实时同步物理到节点
Laya.physicsTimer.frameLoop(1, this, _sysPhysicToNode);
//监听节点变化,同步到物理世界
var sp:* = owner as Sprite;
//如果节点发生变化,则同步到物理世界(仅限节点本身,父节点发生变化不会自动同步)
if (sp._$set_x && !sp._changeByRigidBody) {
sp._changeByRigidBody = true;
function setX(value:*):void {
sp._$set_x(value);
_sysPosToPhysic();
}
_overSet(sp, "x", setX);
function setY(value:*):void {
sp._$set_y(value);
_sysPosToPhysic();
};
_overSet(sp, "y", setY);
function setRotation(value:*):void {
sp._$set_rotation(value);
_sysNodeToPhysic();
};
_overSet(sp, "rotation", setRotation);
}
}
/**@private 同步物理坐标到游戏坐标*/
private function _sysPhysicToNode():void {
if (type != "static" && _body.IsAwake()) {
var pos:* = _body.GetPosition();
var ang:* = _body.GetAngle();
var sp:* = owner as Sprite;
//if (label == "tank") console.log("get",ang);
sp._$set_rotation(Utils.toAngle(ang) - Sprite(sp.parent).globalRotation);
if (ang == 0) {
var point:Point = sp.parent.globalToLocal(
Point.TEMP.setTo(pos.x * Physics.PIXEL_RATIO + sp.pivotX,
pos.y * Physics.PIXEL_RATIO + sp.pivotY), false, Physics.I.worldRoot);
sp._$set_x(point.x);
sp._$set_y(point.y);
} else {
point = sp.globalToLocal(Point.TEMP.setTo(pos.x * Physics.PIXEL_RATIO,
pos.y * Physics.PIXEL_RATIO), false, Physics.I.worldRoot);
point.x += sp.pivotX;
point.y += sp.pivotY;
point = sp.toParentPoint(point);
sp._$set_x(point.x);
sp._$set_y(point.y);
}
}
}
注意Laya.physicsTimer.frameLoop(1, this, _sysPhysicToNode)
,每帧把world.CreateBody创建出来的b2body坐标同步,当然参考坐标就是Physics.I.worldRoot。另外也能从这段代码,验证类开头的注释:
/**
* 2D刚体,显示对象通过RigidBody和物理世界进行绑定,保持物理和显示对象之间的位置同步
* 物理世界的位置变化会自动同步到显示对象,显示对象本身的位移,旋转(父对象位移无效)也会自动同步到物理世界
* 由于引擎限制,暂时不支持以下情形:
* 1.不支持绑定节点缩放
* 2.不支持绑定节点的父节点缩放和旋转
* 3.不支持实时控制父对象位移,IDE内父对象位移是可以的
* 如果想整体位移物理世界,可以Physics.I.worldRoot=场景,然后移动场景即可
* 可以通过IDE-"项目设置" 开启物理辅助线显示,或者通过代码PhysicsDebugDraw.enable();
*/
5.在拉小登博客 让刚体听我的——ApplyForce、ApplyImpulse、SetLinearVelocity中介绍了原生方式,laya也做了相应的封装:
(1)ApplyForce方法会在刚体上施加一个力。学过物理力学的同学都知道,F=ma,有了力F就有了加速度a,有了加速度,物体就会有速度,就会慢慢动起来。(但是不会立马动起来,因为力不会直接影响速度)。
/**
* 对刚体施加力
* @param position 施加力的点,如{x:100,y:100},全局坐标
* @param force 施加的力,如{x:0.1,y:0.1}
*/
public function applyForce(position:Object, force:Object):void {
if (!_body) _onAwake();
_body.ApplyForce(force, position);
}
/**
* 从中心点对刚体施加力,防止对象旋转
* @param force 施加的力,如{x:0.1,y:0.1}
*/
public function applyForceToCenter(force:Object):void {
if (!_body) _onAwake();
_body.applyForceToCenter(force);
}
(2)与ApplyForce不同,ApplyImpulse不会产生力,而是直接影响刚体的速度。通过ApplyImpulse方法添加的速度会与刚体原有的速度叠加,产生新的速度。
/**
* 施加速度冲量,添加的速度冲量会与刚体原有的速度叠加,产生新的速度
* @param position 施加力的点,如{x:100,y:100},全局坐标
* @param impulse 施加的速度冲量,如{x:0.1,y:0.1}
*/
public function applyLinearImpulse(position:Object, impulse:Object):void {
if (!_body) _onAwake();
_body.ApplyLinearImpulse(impulse, position);
}
/**
* 施加速度冲量,添加的速度冲量会与刚体原有的速度叠加,产生新的速度
* @param impulse 施加的速度冲量,如{x:0.1,y:0.1}
*/
public function applyLinearImpulseToCenter(impulse:Object):void {
if (!_body) _onAwake();
_body.ApplyLinearImpulseToCenter(impulse);
}
(3)setLinearVelocity与ApplyImpulse一样,直接影响刚体的速度。不一样的是,setLinearVelocity添加的速度会覆盖刚体原有的速度。
/**
* 设置速度,比如{x:10,y:10}
* @param velocity
*/
public function setVelocity(velocity:Object):void {
if (!_body) _onAwake();
_body.SetLinearVelocity(velocity);
}
(4)旋转
/**
* 对刚体施加扭矩,使其旋转
* @param torque 施加的扭矩
*/
public function applyTorque(torque:Number):void {
if (!_body) _onAwake();
_body.ApplyTorque(torque);
}