最近有个需求需要实现弹性碰撞,需要用到物理引擎实现弹性碰撞。比较场景的物理引擎是 box2d,有一个 Java 版本的 jbox2d 则可以在 Android 上运行。
jbox2d 的地址是 https://github.com/jbox2d/jbox2d,jbox2d 内部模拟了真实的物理世界里物体的运动规则,引擎把计算出的坐标告诉使用者,使用者可以通过这些坐标去完成最终的绘制。
基本概念
开始编写我们的碰撞 demo 之前,我们先了解一下 box2d 里面常用的一些基础概念。
- shape 形状,就是我们理解的那个形状
- body 刚体,就是一个物体,刚体是一个力学概念。指的是一个物体内力做功之和为0,因此刚体在外力作用下发生的形变可以忽略,即刚体上任意两点的距离是保持不变的
- fixture 固定装置,这个可以绑定一些特性给物体,例如密度,摩擦力等等
- world 世界,这个世界代表的应该就是物理引擎里面的物理世界,相当于是各个概念的一个集合。box2d 里的各种概念构成了这个物理世界
实现效果
基于上面这些概念,我希望用 jbox2d 去实现一个这样的效果:底部发射小球,当小球碰撞到手机屏幕边缘的时候,小球会弹开,并且在重力的作用下小球的运动速度逐渐减弱最终会在底部停止。这里先看下最后的实现效果:
http://mpvideo.qpic.cn/0b2eluaasaaal4aap4tc3brfaxodbfoqacia.f10002.mp4?dis_k=e3d94c34a34617794b3bafc11c4dcd5a&dis_t=1652187132&vid=wxv_2365604422866501633&format_id=10002&support_redirect=0&mmversion=false
实现思路
我们把小球放在屏幕的最下面,整个弹射碰撞的过程有几个必须的要素:
- 边界 :这里我们把屏幕四个边作为碰撞的边界,边界宽高就是屏幕宽高
- 小球:一个运动中的刚体,主要还要依赖它自身的一些物理属性
- 重力:世界本身是有重力的,重力的方向是设置成往下,和日常一样
- 初始线速度:线速度是一个矢量,用小球的质点在运动时候轨迹的切线来表示,想要小球顺利的弹出去,线速度矢量横竖轴方向大约要设置为:(width / 2, width/2*(height/width))
demo实现
添加依赖
首先依赖 jbox2d 库到工程内:
代码语言:javascript复制implementation group: 'org.jbox2d', name: 'jbox2d-serialization', version: '1.1.0'
implementation group: 'org.jbox2d', name: 'jbox2d-library', version: '2.2.1.1'
创建 jbox2d 相关的内容
我们把 Jbox2d 相关的逻辑封装在一个 JboxImple
类内,这个类主要负责几件事:
- 初始化 World
- 构造边界
- 构造运动刚体
- 开始运动,获取计算结果
首先初始化 World, 需要给 World 一个重力,我们设置成和现实一样,这里图个方便写成 10f,方向是向下的,所以是正数:
代码语言:javascript复制class JboxImpl {
private val world:World = World(Vec2(0f,10f))
}
接下来要确定世界的大小,我们的世界映射到 APP 内其实就是屏幕,所以世界的大小就是屏幕的宽高,但是笔者试了下,如果完全设置的一样,那么box2d计算的会比较慢,所以这里我们还需要弄个屏幕宽度和世界宽度的比例,把世界宽度设置成10,后续的计算都通过比例计算,所以还需要几个全局的变量:
代码语言:javascript复制const val WIDTH = 1f // 常量,小球在世界的宽
const val WIDTH_WORLD = 10f // 常量,世界宽
private var width = 0f // 宽度
private var height = 0f // 高度
private var ratio = 1f // 高宽比
private var ratioForBox2dToScreen = 1f // 屏幕宽和世界宽的比例
因为最后需要把 jbox2d 计算的结果反馈到 View 层,所以需要暴露一个设置宽高的地方:
代码语言:javascript复制fun onSizeChanged(w: Int, h: Int) {
width = w.toFloat()
height = h.toFloat()
ratio = height / width
ratioForBox2dAndScreen = width/ WIDTH_WORLD
initEdges()
}
在这里我们构造我们的边界:
代码语言:javascript复制fun initEdges() {
// 创建边界
val edgeList= listOf(
Vec2(0f,0f),
Vec2(WIDTH_WORLD, 0f),
Vec2(WIDTH_WORLD, WIDTH_WORLD*ratio),
Vec2(0f, WIDTH_WORLD*ratio),
)
for (i in 0..3) {
val bodyDef = BodyDef()
bodyDef.type = BodyType.STATIC
val body = world.createBody(bodyDef)
val boxShape = EdgeShape()
boxShape.set(edgeList[i], edgeList[(i 1)%4])
val fixtureDef = FixtureDef()
fixtureDef.shape = boxShape
fixtureDef.density = 1f
fixtureDef.restitution = 1f
body.createFixture(fixtureDef)
}
}
这里通过 EdgeShape
围了四个边,每个边创建了静态的刚体。这里需要注意一下 restitution
这个属性,这个指的是弹性恢复系数,取值在[0,1]之间。当r是0的时候,碰撞为完全非弹性碰撞,为1的时候,为完全弹性碰撞。一般来说弹射效果都是非弹性碰撞,所以千万不要把这个值漏设或者设为接近0的,不然你会发现碰撞之后小球看起来更像是往上跑了,而不是“反弹”。
接下来我们创建运动刚体:
代码语言:javascript复制fun createBody() {
val bodyDef = BodyDef()
bodyDef.type = BodyType.DYNAMIC
bodyDef.position = Vec2((WIDTH_WORLD/2), WIDTH_WORLD*ratio)
bodyDef.linearVelocity = Vec2(WIDTH_WORLD, -WIDTH_WORLD*ratio)
val circleShape = CircleShape()
circleShape.radius = WIDTH/2
val ballFixtureDef = FixtureDef()
ballFixtureDef.shape = circleShape
ballFixtureDef.density = 1f
}
val ballBody = world.createBody(bodyDef)
ballBody.createFixture(ballFixtureDef)
}
这里刚体创建的形状是 CircleShape
,起始坐标是屏幕下方 1/2 处,即 ((WIDTH_WORLD/2), WIDTH_WORLDratio)。因为小球初始运动方向在竖轴上是往上的,所以需要设置为负数:(WIDTH_WORLD, -WIDTH_WORLDratio)。
最后我们提供一个重绘方法,内部很简单,就是调用 World
的 step
方法,这里会进行每一步的计算:
fun invalidate() {
world.step(16/1000f,20,20)
}
这里 step 有 3 个参数
- dt:每一步的时间间隔,单位是秒。demo里我就每一帧获取一次
- velocityIterations 和 positionIterations, 速度和位置的迭代次数,大部分物理引擎都有的属性,设的越大,计算精度越高,开销也越大
这些值在实际需求里还是需要进行调整的。这里 jbox2d 相关的东西都做好了,接下来要做的就是把计算结果告诉 Android 的 View,让View去绘制。
View层绘制
创建一个自定义View来绘制我们的小球:
代码语言:javascript复制class JboxView @JvmOverloads ocnstructor(context:Context,attrs:AttributeSet?=null)
: View(context, attrs) {
private var ratioForBox2dAndScreen = 1f
private val paint by lazy {
Paint().apply {
style = Paint.Style.FILL
color = Color.RED
}
}
val jboxImpl by lazy { JboxImpl() }
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w,h,oldw,oldh)
jboxImpl.onSizeChanged(w,h)
ratioForBox2dAndScreen = w/ WIDTH_WORLD
}
}
在ondraw里面,我们需要获取 World
的回调数据,根据 jbox2d 内的坐标和屏幕映射比例计算出实际的View坐标。World
的 getBodyList
可以获取到世界里所有的 Body
,坐标则根据 Body
的 getPosition
获取。注意这里我们只需要获取代表运动刚体的 Body
, 可以根据 type 是 Dynamic
的进行选择:
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
val list = jobxImpl.world.bodyList
while (list != null) {
if (list.type == BodyType.DYNAMIC) {
val position = list.position
val x = (position.x- WIDTH/2)*ratioForBox2dAndScreen
val y = (position.y- WIDTH/2)*ratioForBox2dAndScreen
val radius = WIDTH/2*ratioForBox2dAndScreen
canvas.drawCircle(x,y,radius,paint)
}
list = list.next
}
jobxImpl.invalidate()
invalidate()
}
最后我们通过点击触发一次碰撞运动:
代码语言:javascript复制// in activity
jboxView.jboxImpl.startWorld()
// in JboxImpl
fun startWorld(){
createBody()
invalidate()
}
总结
这里就完成了一个碰撞效果的demo,实际需求中我们会基于这些 api 做更加复杂的效果。使用box2d非常适合完成一些复杂的碰撞动效,尤其是希望运动轨迹符合真实的物理定律的。从效果看还是很棒的,box2d里面还有其他的一些概念例如关节之类的,物理引擎在一些游戏的开发中也是非常重要的地位,感兴趣的朋友也可以进一步研究。