three.js学习笔记(十)——物理引擎

我们可以利用数学函数和一些解决方案像RayCaster来实现自己的物理效果,但是如果需求更加真实的物理效果,像是物体张力、摩擦力、拉伸、反弹等真实物理效果,最好使用外部库

原理

我们会创建一个Three.js世界和一个Physics物理世界,虽然我们看不见后者但它是真实存在的,每当我们往Three.js世界添加对象时,相应的物理世界也会添加相同对象。物理世界在每一帧更新时都会相应更新到Three.js世界中。
例如物理世界中的球体在地板上进行真实弹跳效果时,我们会取其每一帧更新后的坐标并将坐标应用到Three.js世界中的对应球体。

我们需要决定要使用3D库还是2D库,因为有些时候一些3D交互可能被简化为2D,像打桌球,游泳等。比如这个网站3D弹球,弹球只在平面运动,而不会涉及到垂直方向上的弹跳。

3D库

Ammo.js
Cannon.js
Oimo.js

2D库

Matter.js
P2.js
Planck.js
Box2D.js

Cannon.js

本次我们学习使用cannon.js库

安装并引入

npm i --save cannon
import CANNON from 'cannon'

初始场景

通过cannon.js创建物理世界

// 实例化物理world

const world = new CANNON.World()

往world中通过gravity属性添加重力,为三维向量Vec3(Cannon.js的Vec3等价于Three.js的Vector3)

world.gravity.set(0,-9.82,0)

在物理世界中添加球状刚体

在Three.js中我们是通过Mesh创建物体,在Cannon.js中则是通过Body创建刚体,这些刚体Bodies可以坠落并且能其他物体进行碰撞。
但首先,创造刚体Body前得先有一个形状shape,就像我们之前创造网格Mesh前得先有几何体geometry

//创建球形
const sphereShape = new CANNON.Sphere(0.5) //半径0.5,与Three.js世界中的球体半径相同

然后我们创建带有质量mass与初始位置position的球状刚体,类似Three.js创建网格。
关于质量mass属性,如果俩个物体进行碰撞,质量小的那个更容易被撞开,可以想象现实情况。

//创建物理世界中的球体
const sphereBody = new CANNON.Body({//质量mass:1,//位置position:new CANNON.Vec3(0,3,0),shape:sphereShape,
})

通过addBody()方法将球状刚体添加到world

world.addBody(sphereBody)

更新物理世界和Three.js场景

为更新物理世界world,我们必须使用时间步长step(...)

step ( dt , [timeSinceLastCalled] , [maxSubSteps=10] )
dt:固定时间戳(要使用的固定时间步长)
[timeSinceLastCalled]:自上次调用函数以来经过的时间
[maxSubSteps=10]:每个函数调用可执行的最大固定步骤数

回到动画函数,我们希望以60fps的速度运行所以第一个参数设置为 1/60;对于第二个参数,我们需要计算自上一帧以来经过了多少时间,通过将前一帧的elapsedTime减去当前elapsedTime来获得,不要去使用Clock类中的getDelta()方法

/*** Animate*/
const clock = new THREE.Clock()
let oldElapsedTime = 0
const tick = () =>
{const elapsedTime = clock.getElapsedTime()const deltaTime = elapsedTime - oldElapsedTimeoldElapsedTime = elapsedTime//更新物理世界world.step(1/60,deltaTime,3)// Update controlscontrols.update()// Renderrenderer.render(scene, camera)// Call tick again on the next framewindow.requestAnimationFrame(tick)
}

虽然看上去没什么变化,但是实际上物理世界的球体sphereBody一直在不停下落

console.log(sphereBody.position.y)


所以我们可以使用物理世界的球体坐标来更新Three.js世界中的球体坐标,设置之后会看到上图中的球体从(0,3,0)的高处下落穿过地面,因为物理世界中还没有添加地面

	//更新Three.js世界球体的坐标sphere.position.x = sphereBody.position.xsphere.position.y = sphereBody.position.ysphere.position.z = sphereBody.position.z//或者使用下面代码(等价于上面)sphere.position.copy(sphereBody.position)

添加平面到物理世界

设置地面质量mass为0,表面这个body是静态的
注:我们可以创建一个由多种形状shape组成的刚体body

//创建物理世界地面形状
const floorShape = new CANNON.Plane()
//创建物理世界地面
const floorBody = new CANNON.Body()
floorBody.mass = 0
floorBody.addShape(floorShape)
world.addBody(floorBody)

由于平面初始化是是竖立着的,所以需要将其旋转至跟Three.js平面一样。
在cannon.js中,我们只能使用四元数(Quaternion)来旋转,可以通过setFromAxisAngle(…)方法,第一个参数是旋转轴,第二个参数是角度

floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(-1,0,0),Math.PI*0.5)

联系材质ContactMaterial

观察上面动图发现当球体下落与地面碰撞后没有非常明显的反弹行为。
我们可以通过设置材质Material来更改摩擦和反弹行为。

  1. 先创建混凝土和塑料材质
//创建混凝土材质
const concreteMaterial = new CANNON.Material('concrete')
//创建塑料材质
const plasticMaterial = new CANNON.Material('plastic')
  1. 创建联系材质ContactMaterial并通过addContactMaterial方法将其添加到world中
    联系材质定义:两种材质相遇时发生的情况

ContactMaterial ( m1, m2 , [options] )
前两个参数是材质,第三个参数是一个包含碰撞属性的对象,如摩擦(摩擦多少)和恢复(反弹多少),两者的默认值均为0.3

//创建联系材质
const concretePlasticMaterial = new CANNON.ContactMaterial(concreteMaterial,plasticMaterial,{friction: 0.1,restitution: 0.7,}
)
//添加联系材质
world.addContactMaterial(concretePlasticMaterial)

然后给球体sphereBody材质属性设置塑料材质

//创建物理世界球体
const sphereBody = new CANNON.Body({......//塑料材质material: plasticMaterial,
})

给地面floorBody设置混凝土材质

floorBody.material = concreteMaterial

  1. 或者我们也可以简化一下,用默认材质来替代上面的混凝土和塑料材质
//创建默认材质
const defaultMaterial = new CANNON.Material('default')
//创建默认联系材质
const defaultContactMaterial = new CANNON.ContactMaterial(defaultMaterial,defaultMaterial,{friction:0.1,restitution: 0.7,}
) 
//把默认联系材质添加到世界中
world.addContactMaterial(defaultContactMaterial)

然后修改sphereBody和floorBody的material属性,得到一样的结果

4.或者我们直接设置世界的默认联系材质defaultContactMaterial属性,然后移除sphereBody和floorBody的material属性
这样world中的所有材质就都是相同的默认材质。
这些操作通常取决于项目要求的真实程度

world.defaultContactMaterial = defaultContactMaterial

施加外力

applyLocalForce

对刚体body中的局部点施加力。
applyLocalForce ( force , localPoint )
force —— 要应用的力向量(Vec3)
localPoint —— body中要施加力的局部点(Vec3)

下面在球体中心原点处施加一个力(动画函数外部),在页面刷新完成那一帧施加力

sphereBody.applyLocalForce(new CANNON.Vec3(100,0,0),new CANNON.Vec3(0,0,0))

applyForce

在世界world中的的局部点施加力,这个力会作用到刚体body表面,例如风力
applyForce ( force , worldPoint )
force —— 力的大小(Vec3)
worldPoint —— 施加力的世界点(Vec3)

下面用applyForce方法来模拟一股与球体运动反方向的持续的风。
因为要像风一样不断的持续施加力,所以回到动画函数,我们要在更新物理世界前更新每一帧动画。

//风力大小0.5方向反向,施力的世界点位置与球状刚体位置一致,
sphereBody.applyForce(new CANNON.Vec3(-0.5,0,0),sphereBody.position)
//Update physics world
world.step(1 / 60, deltaTime, 3)

处理多个对象

分别移除物理世界和可视世界中的球体,还有动画函数中球体的设置。

1.创建createSphere函数生成球体

//创建用以保存更新网格和刚体对象的数组
const objectToUpdate = []
//创建生成球体函数
const createSphere = (radius,position) => {//Three.js 网格const mesh = new THREE.Mesh(new THREE.SphereBufferGeometry(radius,20,20),new THREE.MeshStandardMaterial({metalness: 0.3,roughness: 0.4,envMap: environmentMapTexture,}))mesh.castShadow = truemesh.position.copy(position)scene.add(mesh)//Cannon.js 刚体const shape = new CANNON.Sphere(radius)const body = new CANNON.Body({mass:1,position:new CANNON.Vec3(0,3,0),shape,material:defaultMaterial})body.position.copy(position)world.addBody(body)//保存对象更新数组中objectToUpdate.push({mesh:mesh,body:body})
}

之后在动画函数中,在更新物理世界后更新网格位置

const clock = new THREE.Clock()
let oldElapsedTime = 0
const tick = () => {const elapsedTime = clock.getElapsedTime()const deltaTime = elapsedTime - oldElapsedTimeoldElapsedTime = elapsedTime//Update physics worldworld.step(1 / 60, deltaTime, 3)for(const object of objectToUpdate) {object.mesh.position.copy(object.body.position)}// Update controlscontrols.update()// Renderrenderer.render(scene, camera)// Call tick again on the next framewindow.requestAnimationFrame(tick)
}

2.添加至DAT.GUI

添加一个createSphere按钮到GUI面板中,每点击一次按钮便生成一个球体。

//先创建对象来存储createSphere函数
//因为gui.add()第一个参数必须是一个对象,第二个参数是对象的一个属性
const debugObject = {}
debugObject.createSphere = () => {createSphere(Math.random() * 0.5, {x: (Math.random() - 0.5) * 3,y: 3,z: (Math.random() - 0.5) * 3,})
}
gui.add(debugObject,'createSphere')

3.优化函数

因为网格的几何体和材质都是一样的,所以我们可以将其移到外面,并把sphereGeometry的半径设为 1 ,然后在函数中将网格根据半径参数进行缩放

//创建用以保存更新网格和刚体对象的数组
const objectToUpdate = []
const sphereGeometry = new THREE.SphereBufferGeometry(1,20,20)
const sphereMaterial = new THREE.MeshStandardMaterial({metalness: 0.3,roughness: 0.4,envMap:environmentMapTexture
})
const createSphere = (radius, position) => {//Three.js 网格const mesh = new THREE.Mesh(sphereGeometry,sphereMaterial)mesh.scale.set(radius,radius,radius)mesh.castShadow = truemesh.position.copy(position)scene.add(mesh)//Cannon.js 刚体const shape = new CANNON.Sphere(radius)const body = new CANNON.Body({mass: 1,position: new CANNON.Vec3(0, 3, 0),shape,material: defaultMaterial,})body.position.copy(position)world.addBody(body)//保存对象更新数组中objectToUpdate.push({mesh: mesh,body: body,})
}

4.同样步骤创建createBox函数生成立方体

传入的参数将是width,height,depth,position。
不过有一点需要注意,cannon.js中创建box与three.js创建box不一样。
在three.js中,创建几何体BoxBufferGeometry只需要直接提供立方体的宽高深就行,而在cannon.js中,它是根据立方体对角线距离的一半来计算生成形状,因此其宽高深必须乘以0.5

const createBox = (width,height,depth,position) => {//Three.js 网格const mesh = new THREE.Mesh(boxGeometry,boxMaterial)mesh.scale.set(width,height,depth)mesh.castShadow = truemesh.position.copy(position)scene.add(mesh)//Cannon.js 刚体const shape = new CANNON.Box(new CANNON.Vec3(width * 0.5,height * 0.5,depth * 0.5))const body = new CANNON.Body({mass: 1,position: new CANNON.Vec3(0, 3, 0),shape,material: defaultMaterial,})body.position.copy(position)world.addBody(body)//保存对象更新数组中objectToUpdate.push({mesh: mesh,body: body,})
}
debugObject.createBox = () => {createBox(Math.random(), Math.random(), Math.random(), {x: (Math.random() - 0.5) * 3,y: 3,z: (Math.random() - 0.5) * 3,})
}gui.add(debugObject,'createBox')


可以看到效果有点违和,因为立方体是被弹开而不是倒下。为此我们将在动画函数中修改代码,把刚体的quaternion复制给网格的quaternion。
关于四元数quaternion,用以表示对象局部旋转。

for (const object of objectToUpdate) {object.mesh.position.copy(object.body.position)object.mesh.quaternion.copy(object.body.quaternion)}

碰撞检测性能优化

1.粗测阶段(BroadPhase)

cannon.js会一直测试物体是否与其他物体发生碰撞,这非常消耗CPU性能,这一步成为BroadPhase。当然我们可以选择不同的BroadPhase来更好的提升性能。
NaiveBroadphase(默认) —— 测试所有的刚体相互间的碰撞。
GridBroadphase —— 使用四边形栅格覆盖world,仅针对同一栅格或相邻栅格中的其他刚体进行碰撞测试。
SAPBroadphase(Sweep And Prune) —— 在多个步骤的任意轴上测试刚体。
默认broadphase为NaiveBroadphase,建议切换到SAPBroadphase
当然如果物体移动速度非常快,最后还是会产生一些bug。
切换到SAPBroadphase只需如下代码

world.broadphase = new CANNON.SAPBroadphase(world)

2.睡眠Sleep

虽然我们使用改进的BroadPhase算法,但所有物体还是都要经过测试,即便是那些不再移动的刚体。
因此我们需要当刚体移动非常非常缓慢以至于看不出其有在移动时,我们说这个刚体进入睡眠,除非有一股力施加在刚体上来唤醒它使其开始移动,否则我们不会进行测试。
只需以下一行代码即可

world.allowSleep = true

当然我们也可以通过Body的sleepSpeedLimit属性或sleepTimeLimit属性来设置刚体进入睡眠模式的条件。
sleepSpeedLimit ——如果速度小于此值,则刚体被视为进入睡眠状态。
sleepTimeLimit —— 如果刚体在这几秒钟内一直处于沉睡,则视为处于睡眠状态。

事件

我们可以监听刚体事件像是碰撞colide、睡眠sleep或唤醒wakeup
下面我们给刚体碰撞事件添加音效。
注意:有些浏览器(如Chrome)会阻止播放声音,除非用户与页面进行交互(像点击任意地方)

创建音效并添加到立方体碰撞事件中

const hitSound = new Audio('/sounds/hit.mp3')
//播放音效
const playHitSound = ()=>{hitSound.play()
}
const createBox = (width,height,depth,position) => {......body.position.copy(position)//碰撞事件body.addEventListener('collide',playHitSound)world.addBody(body)......
}

之后你会发现音效是有了,但是很违和,因为碰撞音效非常规律,明显与实际不符。这是因为当声音在播放的时候我们再调用hitSound.play()是不会发生任何事情的,因为声音已经是在播放状态了。为此我们需要使用currentTime属性将声音重置为重头开始播放

const playHitSound = ()=>{hitSound.currentTime = 0hitSound.play()
}

第二个问题就是当物体碰撞之后哪怕只是非常细微的碰撞,也会发出声音,这就导致声音异常繁多冗杂。为此,我们需要知道碰撞强度,如果碰撞强度不是很高,那我们将忽略其音效。
碰撞强度可以通过contact属性中的getImpactVelocityAlongNormal()方法获取到

因此我们只要当碰撞强度大于某个值时再触发音效就行了

const playHitSound = collision => {const impactStrength = collision.contact.getImpactVelocityAlongNormal()if (impactStrength > 1.5) {hitSound.currentTime = 0hitSound.play()}
}

重置场景

//重置场景
debugObject.reset = () => {for (const object of objectToUpdate) {//移除刚体bodyobject.body.removeEventListener('collide', playHitSound)world.removeBody(object.body)// 移除网格meshscene.remove(object.mesh)}
}gui.add(debugObject,'reset')

其他

Web Worker

由于JavaScript是单线程模型,即所有任务只能在同一个线程上面完成,前面的任务没有做完,后面的就只能等待,这对于日益增强的计算能力来说不是一件好事。所以在HTML5中引入了Web Worker的概念,来为JavaScript创建多线程环境,将其中一些任务分配给Web Worker运行,二者可以同时运行,互不干扰。Web Worker是运行在后台的 JavaScript,独立于其他脚本,不会影响页面的性能。

在计算机中做物理运算的是CPU,负责WebGL图形渲染的是GPU。现在我们的所有事情都是在CPU中的同一个线程完成的,所以该线程可能很快就过载,而解决方案就是使用worker
我们通常把进行物理计算的部分放到worker里面,具体可看这个例子的源码web worker example

cannon-es

cannon.js已经有四年没维护,但还是有些人在其基础上更新并修复细节。
安装并引入

npm i --save cannon-es@0.15.1
import * as CANNON from 'cannon-es'

本文链接:https://my.lmcjl.com/post/12371.html

展开阅读全文

4 评论

留下您的评论.